Field locomotion
What happens, every frame, between holding a d-pad direction and Vahn taking a step in a town, a dungeon or on the overworld. The controller reads the pad relative to the camera, inches the player forward two units at a time against a per-scene wall grid, settles the height afterwards, and fires doors and story beats on the tile it lands on. Ported byte-exact
At a glance
- Controller
- One routine in the field overlay (PROT
0897), shared by towns, dungeons and the overworld walk mode. Not the tile-board puzzle mode that shares the overlay. - Collision grid
- Per-scene, one byte per 128-unit tile: high nibble = four sub-cell wall bits, low nibble = floor tier. Streamed from the scene's
.MAPfile at load; scripts paint story-conditional changes on top. - Speed
- 480 units (3.75 tiles) per second walking, 720 running - counted in vsyncs, so the same on a busy frame as an empty one.
- Height
- A second controller after the walk: floor sampled from the grid's low nibble, or from per-tile ramp records; ledges trigger a scripted hop.
- Engine
World::step_field_locomotion(engine-core); opt-in enhancements--edge-collision,--solid-npcs, precise movement (R).- Confidence
- Confirmed - runtime write-watchpoints on the player position, wall-press captures, disassembly (how we know).
One frame of walking
- Direction. The remapped mask names axes:
0x1000Z+,0x4000Z−,0x2000X+,0x8000X−. Diagonals are two axes resolved independently. The heading (+0x26) snaps to one of eight angles. - Wall slide. When the pad's direction is blocked, three probes 62 units ahead (centre, ±33 lateral) sweep an offset table across the travel axis and add the slide direction the sweep's sign picks - so the player skids along walls instead of sticking. Pure diagonals and symmetric dead ends add nothing.
- Step loop.
speed = ((base × mult) >> 12) × frame_scalarunits are spent 2 at a time; each axis is committed only if its probe is clear. The loop plays no sound - walking and wall contact are silent. - Animation follows motion. A committed step selects the walk (or run) clip; no step selects idle. The pad is never consulted for the clip.
How fast is a frame?
Retail runs two clocks: the vsync (60/s) and the game tick, one pass of the master frame driver. A scratchpad byte, the frame scalar, says how many vsyncs the last tick spanned (1..4, floored at 2 in field scenes and 3 on the overworld). Every duration and velocity inside the tick is multiplied by it, so a cadence change moves the sample rate, never the speed.
| Base step | Selector | Units / second | Tiles / second |
|---|---|---|---|
5 | forced slow | 300 | 2.34 |
8 | plain walk | 480 | 3.75 |
0xC | run | 720 | 5.63 |
0x18 | debug turbo | 1440 | 11.25 |
The port ticks once per vsync with the scalar at 1 - 60 steps of 8 instead of 30 of 16 - and both hosts drive that tick at 60 Hz from a wall clock. Reading a per-frame number in the wrong clock is where "the player moves 1.67× too fast" comes from.
The field buffer: where walls come from
Nothing in a town's script says "this is a wall". The scene loader streams the scene's .MAP file into a per-scene buffer; the collision grid is one region of it.
.MAP file is the first 0x12000 bytes of this buffer, streamed by CD-DMA at scene load; the walkability grid is its +0x4000 region.| Region | Content | Filled by |
|---|---|---|
+0x0000 | Object records, 0x20 bytes each: the buildings, fences and props (anchor tile, mesh index, rotation) | the .MAP file |
+0x4000 | Collision + floor grid, 0x80-byte rows: high nibble four sub-cell wall bits, low nibble floor tier | .MAP; story deltas painted by op 4C 7s |
+0x8000 | Per-tile word: low 9 bits object index, 0x400 object footprint, 0x800 elevation override | .MAP; footprint bits stamped at scene init |
+0x10000 | Trigger block: kind 0 teleports, kind 1 record triggers, kind 2 ramp heights, kind 3 region boxes | .MAP |
+0x12000 | Field pack and efect.dat; also the trigger lookup's fallback window | separate files |
Collision: how a step is tested
Each 2-unit sub-step asks one function whether the tile ahead is a wall and whether an actor is in the way. The world position becomes a tile by (coord + bias) >> 6; the byte's high nibble is masked by the quadrant the point falls in. Three points along the player's leading edge are probed (48 units ahead, ±16 lateral), so the footprint is tested, not the centre. The Z bias is authored into the wall bits: the floor sampler reads the same bytes with plain indexing, so one byte's two nibbles live under two different mappings.
| Blocker | Test | Result bit |
|---|---|---|
| Wall | Sub-cell bit under any of the three edge probes | 2 |
| Static object | ±80-unit box around the record's footprint centre | 1 / 4 |
| Moving NPC | ±40-unit box around its live position; a hit links the pair at +0x98 | 1 / 4 |
Pressing the action button runs a third probe: a radius-64 compass point in the facing sector against a ±72 NPC box; a hit posts the touch event (engaged flag, facing saved) and turns the player toward the NPC. Props fire the same event on contact, no button needed.
Height: two floor models
The walk controller only writes X and Z. After it commits, a second controller re-samples the floor under the new position and glides Y toward it; a third probes ahead along the last step and starts a scripted hop when the floor rises a ledge.
Tile word bit 0x800 | Model | Used for |
|---|---|---|
| clear | Bilinear nibble surface: the four corner tiles' tiers through a 16-entry height LUT, weighted by sub-tile position | flat ground, gentle terrain |
| set | Elevation override: flat mean of the four corner tiers plus the tile's kind-2 record - −32 per whole-tile step, −16 per 64-unit sub-cell step. No interpolation | ramps and staircases |
The kind-2 record replaces the bilinear branch, it is not a fast path on top of it. Rim Elm's shore ramps sit on sea-level nibbles and hold their whole elevation in kind-2 records; interpolating their nibbles walks the player under the drawn stairs. Engine: World::sample_field_floor_height carries both branches (--flat-y opts out).
Triggers: doors, beats and teleports
Crossing onto a new tile (tile = (world − 0x40) >> 7, compared against last frame's) looks the tile up in the trigger block. Scene entry marks the compare stale, so the arrival tile fires on the first tick.
| Kind | Record | What it does |
|---|---|---|
0 | [x][z][dest_half_x][dest_half_z] | Intra-scene teleport - most house exits |
1, gate 1 | [x][z][p2_record][1] | Spawn a MAN partition-2 record if its story-flag gates pass: town exits (the record runs the named scene change), walk-on story beats |
1, gate 0 | [x][z][p0_record][0] | At scene init, bind a partition-0 record to the object on that tile as its touch script and idle clip - house doors, cupboards |
2 | [x][z][coarse:i8][quads:u8] | Ramp / staircase height (above) |
3 | [x0][z0][x1][z1][type], 8 bytes | Region boxes: camera zones, encounter regions |
A beat that should play once self-latches: its script sets the very flag its C1 gate lists, so it runs on the crossing that finds the flag clear and blocks itself afterwards. Rim Elm's dinner chain is the canonical example. The gate bitmap is the field VM's own story-flag bank, so a set is visible to the next record immediately.
Walking into a house
A town house is not a scene change: the interior is a sub-area of the same 128×128 grid parked in an unused corner, and the door repositions the player. Two door mechanisms exist and one house can use one of each.
| Class | Mechanism | Lives in |
|---|---|---|
| Script door | A gate-0 bind whose record teleports the player channel 0xF8 on contact - by instant teleport (A3 F8), teleport + move clip (CC F8 51) or animated walk-to-tile (C7 F8) | MAN partition 0 |
| Map door | A kind-0 teleport tile: no object, no script, no name | .MAP trigger block |
Map doors are the larger class; a MAN-only census cannot see them. Vahn's house is the mixed case: script door in, map door out.
NPCs: facing and glide speed
Initial facing
The placement record has no facing byte. At spawn, the placement installer pre-runs the record's leading ops (a 0x24/0x25 marker up to the first 0x21), and two of them write the heading from the 8-direction LUT at SCUS 0x80073F04: the NPC move-to-tile op 4C 51 (operand +3, low nibble) and CAM_CFG's simple path. Town prologues route that leg through story-flag tests, so the fall-through branch is the fresh-game facing. Rim Elm's side-by-side villagers at tiles (29,22) / (30,22) derive indices 6 and 2 - they face each other.
Heading space: retail 0 = Z−, 0x400 = X−, 0x800 = Z+, 0xC00 = X+; the engine's convention is the same rotated a half-turn. After spawn, walking snaps the heading and the rotate ops ramp it; the last writer in the frame draws (motion VM).
Glide speed
An NPC's pace is not the player's walk step - the walk kernel never reads the speed multiplier. Both NPC walk kernels carry the base step in the walk op's own operands, on the ladder numerator >> (2 + bits) units per vsync.
| Op | Where bits lives | Numerator |
|---|---|---|
0x37 glide | (op0 >> 5 & 4) | (op1 >> 6) | 0x80 |
0x41 glide | same | 0x40 - half speed |
0x47 walk-to-tile | b2 & 7; high nibble = approach mode | 0x80 |
motion-stream steps 03 / 19 / 20 | operand byte 1, low nibble | 0x80 |
motion-stream 06 / 18 | 4-bit selector over the operands' high bits | 0x80 |
Base steps run 32 / 16 / 8 / 4 / 2 / 1 for bits 0..5. There is no synthesised motion bytecode: the field VM parks the op's address on the actor and the walk kernel reads the record bytes in place each frame. Engine: placement_glide_speed decodes the placement's bound motion stream first, then its own yield ops, with a base-step-8 stand-in when neither decodes.
The port
- Faithful path.
step_field_locomotionrotates the pad by the camera azimuth (quantised to 90°, which agrees with the 45° remap for every axis-aligned retail camera) and steps through the same per-axis 2-unit collision. Wall-press captures rest byte-exact. - Opt-in fidelity.
--edge-collisionenables the three-probe leading edge;--solid-npcsenables the actor boxes (static props and moving NPCs). The slide resolver is ported standalone (resolve_field_slide), not yet in the live pad path. - Enhancements. Precise movement (
R, default off): continuous azimuth, true 45° diagonals at unit speed, analog stick angle, sub-step remainder carried across frames. Camera distance presets (T) never touch the simulation. - Scene entry.
.MAPresolved as the slot two below the scene's CDNAME block start (its0x12000footprint identifies it); the MAN's entry script runs on channel0xFB; the MAN's encounter table is installed; input is locked while an opening-cutscene timeline drives the player. - Spawn. Cold entry (New Game, Rim Elm) creates the player at
(0xA40, 0, 0xA40), the centre of the camera window; warp entry lands on the saved door coordinates.
How we know
| Function / data | Address | What it proves | Dump |
|---|---|---|---|
| Locomotion controller | FUN_801D01B0 | Gate, remap, speed, 2-unit step loop; found by a write-watchpoint on the player X/Z (stores at 0x801D0684 / 06E4 / 0744 / 07B4) | autorun_player_pos_watch.lua, field overlay dump |
| Wall collision | FUN_801CFE4C | Biased tile mapping, high-nibble quadrant mask, probe tables DAT_801F21B4 / 2214 | field overlay dump |
| Actor collision | FUN_801CFC40 | Static ±80 / moving ±40 boxes, 0x20000 class bit, +0x98 link | field overlay dump |
| Wall slide | FUN_80046494 | Three-point probe, perpendicular sweep, slide bit | funcs/80046494.txt |
| Pad remap | FUN_800467E8 | Eighth-turn rotation over the 8-direction ring DAT_800766FC | funcs/800467e8.txt |
| Frame scalar | FUN_80016B6C | DAT_1F800393 picked from a VSync(1) measurement with per-mode floors | funcs/80016b6c.txt |
| Floor sampler | FUN_80019278 | Bilinear vs override on cell bit 0x800; LUT at scratchpad 0x1F80035C | funcs/80019278.txt |
| Settle / hop | FUN_801D1BA0 / FUN_801D1878 | Step-delta globals 0x8007BDE0 / E4, hop arc | field overlay dump |
| Walk-on dispatch | FUN_801D1EC4 → FUN_801D5630 → FUN_8003BDE0 | Tile compare, trigger scan with the +0x12000 fallback, C1/C2 gate check | field overlay dump, funcs/8003bde0.txt |
| Object spawn | FUN_8003A55C / FUN_8003AEB0 | Object record fields, bind lookup, footprint bit, LUT fill | funcs/8003a55c.txt |
| Grid source | FUN_8001F7C0 via CD-DMA FUN_8005D9A0 | Write-watchpoint on the live grid caught only the DMA writer: the base grid streams, scripts paint deltas | funcs/8001f7c0.txt |
| Wall paint | 0x801E1C64 (op 4C 7s) | Rect [col0, col1+1) × [row0+1, row1+2); subs 0/1 six bytes, 2/3 seven | overlay_0897_801de840.txt |
| Wall-press captures | PCSX-Redux states | X− press pins the 47-unit edge; Z− press pins the authored Z bias; engine rests byte-exact on both | field_wall_press_*.rs |
| NPC facing / glide | town01 placements | Facing pair (29,22)/(30,22); glide steps decoded from real operands | field_npc_initial_facing_disc.rs, field_npc_glide_speed_disc.rs |
Full write-up, including the hop-arc controller, the door-swing clip binding and the scripted-scene actor programs: docs/subsystems/field-locomotion.md.
Details
Player actor fields
The player actor pointer is _DAT_8007C364. Read +0x14/16/18 as 16-bit: a 32-bit read of +0x14 folds the angle word into X and looks like position drift.
| Offset | Meaning |
|---|---|
+0x10 | flags: 0x80000 movement disabled, 0x1000000 interact requested |
+0x14 / 18 | world X / Z (s16, 128 units per tile) |
+0x16 | terrain-conform angle - not the yaw |
+0x26 | heading, eight angles |
+0x5C | run state (> 0 selects the run clip) |
+0x72 | speed multiplier, 0x1000 = 1.0 |
+0x94 | encounter-record pointer |
+0x98 | interaction-target actor |
Object records and the two spawners
| Offset | Type | Meaning |
|---|---|---|
+0x00 | u16 | X sub-tile offset: world_x = col × 128 + this + 0x40 |
+0x02 | u16 | Y offset added to the tile's LUT floor height |
+0x04 | u16 | Z sub-tile offset: world_z = row × 128 − (this − 0x40) |
+0x06 / 07 | i8 | Footprint column / row delta to the anchor tile |
+0x08..0x0C | u16 × 3 | Rotation triple, 4096 per turn (+0x0A = yaw) |
+0x10 | u16 | Mesh index into the scene TMD pack - for every id; the id selects the record, never the mesh |
+0x12 | u16 | flags; 0x4 = placed |
A placed record becomes an actor through exactly one of two sweeps. The scene-init sweep resolves the record's bind (the gate-0 trigger on its footprint anchor) and skips unbound records; the sub-area window rebuild spawns from the tile words with no bind lookup, gated only by the footprint bit. The sets are complementary (Rim Elm: 37 bound + 9 unbound = 46), so every placed record draws. A bound record with a clip id is posed by that clip's frame 0 - the cupboard's hinge doors hang through the floor without it. Parser legaia_asset::field_objects.
Scene entry: which .MAP, which script, which encounters
- The
.MAPis the PROT slot atdefine − 2from the scene's CDNAME block; the first0x12000entry inside a block belongs to the next scene. The TOC-indexed payload is0x4000bytes; the grid lives in the entry's trailing sectors, so the loader reads the extended footprint. town01's grid matches the live RAM grid with zero diffs. - The entry script is the MAN partition-1 record 0 on channel
0xFB, sliced from the script start so 16-bit-wrapping jumps anchor at the slice base. Its wall paints sit behind story-flag tests: map03 paints tile (66, 102) solid behind flag0x6C2and clears three rects behind0x378; on a fresh boot both are clear. - The same MAN carries the random-encounter table (section 0, installed at
scene + 0x20); towns have real tables too - Rim Elm declares 7 formations at a6/256mean rate, gated by its region records. - Standalone towns use the
count = 6asset-table form in their block's second PROT entry; kingdom-bundle scenes usecount = 7. Both resolve throughfind_bundle.
The scripted-scene actor
Some field actors are small programs rather than NPCs - the hop-arc controller, door swings, one-shot props. FUN_801D4A60 is a 38-state jump table on the actor's +0x54 whose entry state computes +0x54 = +0x54 + 1 + (+0x50 × 10): +0x50 is a program selector and the table is four short programs of 5, 5, 6 and 7 states entered at 1 / 11 / 21 / 31. Spawner FUN_801D5A24(program); port engine-core::field_actor_program.
Open
- The full touch-event handler state beyond its decoded entry kernel.
- Per-actor field-VM channel execution with story-flag branches: the engine loops decoded waypoint lists and takes the fall-through facing leg rather than evaluating the prologue's flag tests against live flags.
History: readings this page replaced
Walls were once thought to be authored entirely by scripts (a static search for CPU stores cannot see the DMA load). The +0x8000 region was read as a terrain-flag grid (it is the object map). The object table was read as NPC spawns rather than buildings (the near-zero +0x08..0x0C fields are the rotation, not the mesh). A positional "mesh band" rule for object ids was falsified by the GPU prim pool. The 4C 51 operand was read as a glide speed (it is a facing nibble). See do-not-re-walk.