♥ lp-engine

Chapter 2

The shape of the game

Underneath the dates and conversations, LovePlus+ is a stack of small state machines that take turns once per frame. Learn the stack and the rest of the game falls into place around it.

Once per frame

The DS draws a new picture about 60 times a second, and the game's main loop, Main_UpdateFrame, does the same few things every time:

  1. Read the buttons and the touch screen (Input_Poll, Touch_Update).
  2. Check the power and the lid (PowerMgr_Process). If the lid stays closed for nine frames, this is where the game puts the DS to sleep.
  3. Advance the game itself (GameHeap_UpdateGameState): everything in the rest of this chapter happens inside this one call.
  4. Draw sprites, move graphics into video memory, run screen fades (Sprite_DrawAll, VramBank_Process, ScreenFade_Process).

A frame in a 3D scene often takes longer than one refresh. The game deliberately waits for the next one, so the heroine animates at about 15 frames per second even on real hardware. That's the game's own pacing, not a slow console. lp-engine's smooth-motion option fills in the frames in between.

A stack of state machines

Every part of the game, from the title screen to a date, is a state machine: a number saying where it is, and a function that does one frame's work for that number. The machines nest. The top one runs a machine for the current activity, which may run another below it, and they all share a single stack of states.

functionwhat it does
Main_IncStateDepth / Main_DecStateDepthA machine steps one level down the stack at the start of its frame, and back up at the end.
Main_GetStateAtDepthReads this machine's state.
Main_SetStateSets this machine's state, and resets every machine below it to 0, freeing their working memory.
Main_AdvanceStateSequenceMoves to the next state.

That reset rule in Main_SetState is what keeps the game tidy: when the top machine moves on, whatever was running underneath is wiped clean. Its old label was Main_FreeStateBuffer, which describes the side effect and misses the point. It's one of the names lp-engine's port corrected.

The top machine

Mod204af08_RunGameStateMachine is the game's director. Its states are spaced out in tens, and each one hands the frame to a different part of the game. The main routes through it:

0–3start-up→10, 11title screen→0x1e, 0x1ftitle menu
continue →0x20catch up the clock→0x28the room⇄0x5aa scene
new game →0x118opening→0x5athe story→0x28the room
from the room →0xd2a date0x6ean event0xf0a new day0x136overnight0x8csettings

Most of the game's time is spent in two of these states. 0x28 is the room, the hub you always come back to. 0x5a runs the scene machine, Mod2049a04_RunMainStateMachine, which plays the story's scenes. When a scene ends it returns an exit code, and the code decides where you go next: usually back to the room, sometimes to a choice, the schedule, or the file select.

Screens

Menus and full-screen activities don't get states of their own. They're screens, run by one shared screen manager. A machine asks for a screen by number, then runs it every frame until it finishes with a result.

functionwhat it does
Screen_RequestAsks for screen n, with an entry argument the screen can read (the room uses it to know why you arrived).
Screen_RunRuns the requested screen; returns −1 while it's running and its result when it finishes.
Screen_SwitchToCalled by a screen to hand over to another one. The machine that asked never notices.
Screen_PostResultCalled by a screen to finish with a result.
Screen_LoadOverlay / Screen_UnloadOverlayEach screen lives in its own overlay, a block of code loaded into memory only while it's in use.

Every one of these was originally labelled as part of the sprite system (Sprite_UpdateAnimationFrame, Sprite_DispatchFrameState, …), from the shape of the code alone. Watching the game run showed what they really do. In the function map they're marked verified, and each one notes its old label.

Screen switching explains a lot of the room. Opening the schedule, the settings or the calendar from the room is a switch inside the screen manager. The top machine stays in state 0x28 the whole time and never knows you left. Even sleeping and the next morning happen inside the room's own overlay: two long play sessions, about 38 minutes of play between them, never left state 0x28.

The room as a hub

When the room screen does finish, its result tells the top machine what to do next:

resultnextresultnext
1the next story scene8settings
4back to the title menu (after saving)12a date: the clock is saved, then the date scene
5a dream13a new day: schedule, calendar and random events updated
6an event, chosen by EventLogic_SelectEvent15overnight

Apart from saving, every branch ends by asking for the room again, with an entry argument that says where you're coming back from.

How a new game reaches the story

  1. On the title menu, Game Start makes SaveSelect_ProcessScreen open the file select.
  2. The file select switches to the clock setting, which switches to the profile, where you write your name by hand (Ov06ProfileDlt_RunProfileScreenStateMachine).
  3. The profile finishes, and SaveSelect returns with the new game flag set.
  4. The top machine goes to the opening (0x118), then to the scene machine (0x5a), which loads the scene overlay and plays the first scenes.
  5. The scene ends with exit code 2: the room is requested, and the top machine settles into its hub, state 0x28.

Continuing a saved game takes the other path: state 0x20 first works out how much time has passed since you last played, so the game knows what she's been doing without you.

How we know

This chapter was first read from the code, then checked against the real game. A Python version of these machines was run against 63 recordings of the game playing in an emulator: 311,719 frames and 5.9 million calls, with every call, argument, memory write and return value compared. It never diverged. The same machines, ported to Rust, now run lp-engine's game flow, and the same recordings check them on every test run.

One gap is known. If you deliberately break one of the scene machine's exit codes, the checks don't always notice. Exit code 6 never occurred in natural play, so it has only been forced, never seen. The detailed notes are in docs/GAME_FLOW.md, which also lists every state and every title-menu branch.