Player actor fields

The controller is FUN_801d01b0 - a RAM address in our disassembler trace, like every FUN_ name below - and it lives in the field overlay (a chunk of code the game streams into RAM when you enter a town or field map), not in the main executable. This is the general locomotion path - not the tile-board minigame that shares the overlay.

The player actor pointer is the global _DAT_8007c364. Confirmed fields:

offsetmeaning
+0x10flags; bit 0x80000 = movement disabled (encounter pending / cutscene), bit 0x1000000 = action/interact requested
+0x14world X (s16)
+0x16renderer facing angle (s16)
+0x18world Z (s16)
+0x26heading (8-direction movement angle, set from the pad direction)
+0x5crun/dash state counter (> 0 switches the walk-animation select)
+0x72per-actor speed multiplier (fixed-point, >> 12)
+0x94encounter-record pointer
+0x98interaction-target actor pointer

World coordinates are plain s16 in 1-unit resolution; one collision tile is 0x80 (128) units. The field camera derives its origin by negating these.

Spawn position on scene entry

The player's spawn position is set by the per-scene initializer FUN_801D6704, not by the locomotion controller. Two cases, selected by the field-entry mode global _DAT_8007b8b8:

  • Cold entry (== 0). The player actor is created at actor coords (0xA40, 0, 0xA40) - the centre of the camera's 0x20-tile view window - while the camera is seeded onto the MAN anchor and then follows the player. Cold entry only ever happens for the New Game opening scene (town01, Rim Elm), so this is effectively Vahn's authored opening spawn (byte-checked walkable against town01's base collision grid).
  • Warp entry (== 2). The spawn carries the sub-tile offset of the saved transition coords _DAT_80084568/_DAT_8008456C, so the player lands at the destination door instead of the window centre.

Engine mirror: FIELD_COLD_SPAWN_XZ, applied in SceneHost::enter_field_scene.

Per-frame flow

  1. Disabled gate. If player.flags & 0x80000 is set, skip all movement (an encounter is queued or a cutscene owns the player).
  2. Action button. An edge-pad action bit (_DAT_8007b874 & 4) plays the confirm SFX and raises player.flags |= 0x1000000 (talk / examine), short-circuiting movement that frame.
  3. Direction decode. func_0x800467e8(&_DAT_8007b850) rewrites the held pad in place into a camera-relative mask (the gp+0x2d8 eighth-turn rotation over the 8-direction ring DAT_800766fc - a 45° remap). FUN_80046494(player) reads that mask and returns the movement direction in bits & 0xf000, resolving diagonals. The heading +0x26 is set to one of eight angle constants. FUN_80046494 is really a wall-slide resolver: when the held direction is blocked it probes the candidate point 62 units ahead along travel (per-direction table DAT_800766bc) three times across the player's width - centre plus ±0x21 lateral (walkability probe func_0x801d56c4) - sweeps a signed offset table perpendicular to the travel axis, and ORs in the slide direction the sweep's sign picks - so the mask can name an axis the pad never asked for, and the player skids along walls instead of sticking to them. Pure diagonals are never slide-resolved (their two axes already resolve independently in the per-axis collision step), and a symmetric dead end adds nothing, leaving the player stopped.
    mask bit (post-remap)axis delta
    0x1000Z +
    0x4000Z −
    0x2000X +
    0x8000X −
  4. Speed. speed = ((base_step * player[+0x72]) >> 12) * DAT_1f800393, where base_step is 8 walking (other values in run / forced states) and DAT_1f800393 is the per-frame delta scalar. Modifiers: terrain-slow (speed >>= 1 on a 0x4000-flagged tile when scene byte _DAT_801c6ea4[+0x61] == 1) and diagonal normalise (speed -= speed >> 2).
  5. Step loop. Advances 2 units per iteration until speed units are consumed; each iteration collision-checks the candidate axis and commits only if clear:
    if (dir & 0x1000) and collide(player, scene, 2) == clear:  player.Z += 2
    else if (dir & 0x4000) and collide(player, scene, 0) == clear:  player.Z -= 2
    if (dir & 0x2000) and collide(player, scene, 3) == clear:  player.X += 2
    else if (dir & 0x8000) and collide(player, scene, 1) == clear:  player.X -= 2
    collide is FUN_801cfe4c; dir-codes are 0 = Z−, 1 = X−, 2 = Z+, 3 = X+. The per-frame delta vector is stored at _DAT_8007bde0 (X) / _DAT_8007bde4 (Z) for the transform-commit + camera follow. The step loop plays no SFX - walking and wall contact are silent; the controller's 0x20/0x23 cues fire in the pre-movement header (action-button / menu-open accept, and the deny buzz when the menu-open is locked out).

Collision - FUN_801cfe4c

FUN_801cfe4c(player, scene, dir) returns 0 when the move is clear and 2 when a static wall blocks it (plus bits 1/4 from the finer FUN_801cfc40 actor/edge probe). It samples a per-scene collision tile map through the base pointer _DAT_1f8003ec:

  • The walkability grid lives at *(_DAT_1f8003ec) + 0x4000.
  • The player world position is converted to tile space by (coord + bias) >> 6; the byte index is (tileX / 2 & 0x7f) + (tileZ * 0x40 & 0x3f80) - rows of 0x40 bytes, up to 0x80 rows.
  • Each map byte's high nibble holds 4 sub-cell walkability bits: the tile is split into a 2×2 quadrant grid, and byte >> 4 & quadrant_mask selects the relevant quadrant. A set bit = wall. So one map byte covers a 128×128 world tile, divided into four 64×64 sub-cells.
  • Direction-specific probe offsets come from tables DAT_801f21b4 / DAT_801f2214 (16-byte stride per direction); three points along the player's leading edge are probed - 48 units ahead in the positive directions, 47 in the negative (the per-direction crossing distance under the biased cell mapping), spread ±16 laterally - so the player footprint, not just its centre, is tested.

The sibling sampler FUN_801d5718 reads the same grid with the identical nibble-and-mask shape, confirming the layout.

Wall-probe mechanics

The sub-cell derivation is exact and biased: the +2 Z bias in the world→cell mapping is authored into the wall bits - the wall band a press blocks against sits one tile north of where a plain floor-indexed read would place it. The floor sampler (FUN_80019278) reads the same bytes with plain floor indexing: one byte's two nibbles are addressed under two different world→cell mappings by their two retail consumers.

Actor-collision probes - FUN_801cfc40

FUN_801cfc40 (result bits 1/4) walks the active-actor table DAT_801c93c8, box-testing the three DAT_801f21b4 probe points (disc-pinned: 64/63 ahead, ±32 lateral - wider than the wall edge) against each actor. A static entity anchors at its MAN object record (tile×128 + sub×16, footprint centre rec[+6]·0x80 + rec[+0xE]·0x10 per axis with a +0x52&8 correction mirrored from record flag bit 0x8) and blocks with an 80-unit half-extent; a moving actor (flags bit 0x20000 - village NPCs are this class) uses its live position with a ±40-unit box, and a hit links the pair mutually at +0x98. The locomotion gates each 2-unit step on the actor bits and the wall bit together, so NPCs block exactly like walls.

The button-press interact runs retail's third probe table DAT_801f2254 (disc-pinned at overlay file 0x23A3C: a radius-64 compass point per 45° facing sector, ±72 NPC box), posts the touch event (FUN_801d5b5c: player engaged flag 0x80000, actor mark 0x100, facing saved to +0x5A, NPC-motion pause kick FUN_8003c9ac), and turns the player toward the matched NPC. Prop walk-touch fires that event automatically per contact step, no button needed. The teardown is the dialog SM's exit path (FUN_80039b7c): facing restored from +0x5A, the +0x2A/+0xA touch-counter pair drained, and the 0x80000 engaged flag + ctrl+0x60 cleared when no interactions remain.

Engine-port status

  • Walls. World::field_tile_is_wall uses the biased derivation verbatim; the three-probe leading-edge footprint is wired (World::field_dir_blocked over the disc-pinned DAT_801f2214 table, opt-in via play-window --edge-collision).
  • Actors. The moving-actor arm is ported over the NPC positions (World::field_actor_dir_blocked, opt-in --solid-npcs; head-on rest 102 units short); the static prop arm box-tests the placed-flag .MAP object placements at the static ±80 half-extent with each record-derived footprint centre, under the same flag (head-on rest 142 short).
  • Walk-touch + interact. World::check_field_walk_touch posts once per ±80-box contact through the interact dispatch and applies the placement's decoded effect (genuine 0x3E door-warp, or the cross-context player-channel 0x23 throw-back teleport).
  • NPC motion. Each placement's own 0x4C 0x51 move-to-tile waypoints drive the ported motion VM (World::tick_field_npc_motions), with live positions feeding the collision and interact probes.
  • Residual. The full FUN_801d5b5c post-kernel state and per-actor field-VM channel execution. (NPC glide speed is decoded per placement from its real walk-kernel operands - see NPC glide speed below.)
Capture proofs

Two cheat-free wall-press captures settle the sub-cell derivation: the X− press validates the 47-unit leading edge at step granularity (the probe reads the wall column's last sub-cell; one 2-unit step shallower reads clear), and the Z− (screen-down) press proves the +2 Z bias is authored into the wall bits - the player legally rests at a position whose plain floor-indexed cell is an all-quads wall byte (unreachable under floor indexing), while the biased read places that wall band one tile north, exactly where the press blocks. Driving the engine stepper over each capture's live grid reproduces both retail rest positions byte-exactly (the candidate-centre default walks deeper), and the same presses inside a full BootSession scene entry (resolver-loaded .MAP + engine-executed prescript paints, through the real pad → camera-remap → locomotion path) rest at the captured positions byte-exactly too.

The rimelm_npc_press_tetsu capture (player pressed into the sparring partner) pins the NPC class from live RAM: the mutual +0x98 collision link is active in-frame both ways and the NPC's flags carry the 0x20000 bit - village NPCs take the moving-actor arm, not the static prop arm. The static prop footprint centres are live-verified against four captures' spawned static actors (field_prop_colliders_live.rs). FUN_801d5b5c's touch-event kernel is decoded from a live overlay image.

Where the collision grid comes from

_DAT_1f8003ec is the base of the per-scene field buffer (a scratchpad-resident pointer at 0x1F8003EC). Its sub-regions:

offset from basecontentfilled by
+0x0000object / actor records (0x20-byte stride; up to 512)scene loader / field VM
+0x4000collision + floor grid - 1 byte/tile, 0x80-byte rows: high nibble = 4 sub-cell wall bits, low nibble = floor-elevation tierbase: the .MAP file's +0x4000 region (FUN_8001f7c0); field-VM 0x4C nibble-7 ops apply conditional deltas
+0x8000per-tile object/attribute map - u16/tile: low 9 bits = object-record index into the +0x0000 table, high bits = per-tile flags (bit 0x400 = object footprint)object placement at scene load; bit 0x400 ORed in by FUN_8003aeb0 from field-pack records
+0x10000trigger block - shared header + four kind sub-tables (for kind k: sub-table offset s16 at +4k+2, count at +4k+4). Kind 0 = intra-scene teleports [x][z][dest_half_x][dest_half_z]; kind 1 = P2-record triggers [tile_x][tile_z][p2_record][gate] (walk-on dispatch FUN_801D1EC4FUN_801D5630(1,x,z)FUN_8003BDE0; gate=1 spawns the record, gate=0 = scene-init object-binds; the opening's map01/town01 records launch here); kind 2 = per-tile elevation overrides [tile_x][tile_z][coarse: i8][quads: u8] - the floor height of every ramp / staircase tile, used instead of the bilinear nibble surface (see Floor height: two models); kind 3 = the region AABB table (FUN_80017FBC; 8-byte [x0,z0,x1,z1,type,0,0,0])the .MAP file's +0x10000 region (FUN_8001f7c0); engine field_regions::{TileTrigger, parse_tile_triggers, RegionTable}
+0x12000field-pack region; _DAT_8007b8d0 = base + 0x12800; also the trigger lookup's fallback window (first sectors of the next PROT entry, same header shape, pulled in by the contiguous 0x28-sector read)FUN_8001f7c0 (scene asset loader)

Engine runtime dispatch. Both kind-1 gate classes run live in the port. Gate 1 - walk-on record spawn (SceneHost::dispatch_walk_on_trigger, the FUN_801D1EC4 per-frame tile compare): crossing into a new tile during free-roam (tile = (world − 0x40) >> 7, compared against a last-tile mirror; scene entry marks the compare stale so the arrival tile fires on the first tick, matching retail's stale globals) spawns the referenced partition-2 record when its C1/C2 story-flag gates pass. This is how town exits work - Rim Elm's south-gate tiles reference the record whose script runs the 0x3F named scene-change to the overworld - and how walk-on story beats launch. The dispatch runs in both field and world-map mode: on the overworld a gate-1 record that IS a portal (carries a 0x3F, tested by p2_record_is_portal) is left to the world-map entity SM, and only non-portal beat records spawn here - the Drake mist-wall force-walk bands (map01 P2[34..36], C1=[0x482]). Gate 0 - object binds: each gate-0 trigger binds its partition-0 record as a touch object at scene init (FUN_8003A55C; engine World::install_trigger_walk_touch). House doors are these - the record's cross-context 0xA3 0xF8 teleports the player through the IN/OUT pair. Partition-0 records carry their own header form [u8 n][n*2 SJIS name][u8 attr] (pc0 = 1 + n*2 + 1), not the partition-1 shape.

The partition-2 gate bitmap (DAT_80085758) is the field VM's 0x50/0x60/0x70 system-flag bank - one store shared by the record dispatcher's C1/C2 test and the VM's flag writes, so an opening-timeline set is immediately visible to the next record's gate. It also overlaps the saved story-flag window at byte +0x158 (0x80085758 − 0x80085600); the engine save mirrors the bank into that window and reloads seed it back. How a spawned record then executes (natural termination at the record's idle park / resident loop-back, inline dialog boxes, unresolved cross-context targets) is covered under the cutscene timeline execution model.

The C1 one-shot-latch idiom. A walk-on beat that should play once self-latches: its script 0x50 SETs the very flag its C1 lists, so it runs on the crossing that finds C1 clear and its own set blocks every later crossing (C1 = block-if-ANY-set). The town01 dinner chain is canonical - P2[4] (C1=[550], sets 550), P2[5] (C1=[551] C2=[550], sets 551); step_cutscene_timeline applies those set ops in the system bank as the record runs (550 latches after the beat), so a completed timeline stops its own re-fire. A record with empty gates is spawned every crossing and self-manages via an internal 0x70 TEST/0x50 SET on a private flag (town01 P2[6]: TEST 558 … SET 558, jumping to its end while the guard is clear - a no-op, not a lock). The overworld mist-wall bands (C1=[0x482]) invert it: no set, live until an external event sets 0x482.

Collision byte: walls + floor height

Each +0x4000 byte packs two nibbles for its 128-unit tile. The high nibble holds the four sub-cell wall bits (sampled by the collision check above). The low nibble is a floor-elevation tier: a 4-bit index 0..15 into a 16-entry short height LUT at scratchpad 0x1f80035c (= 0x1f800314 + 0x48). The object/actor spawn iterator FUN_8003a55c reads LUT[byte & 0xf] and adds it to each placed object's Y, so a tile's collision byte also encodes its floor height (raised platforms, multi-level rooms). The LUT is filled at scene entry by FUN_8003aeb0 from the MAN asset header (_DAT_8007b898 + 2, 16 negated shorts).

Floor height: two models

FUN_80019278 is the runtime floor sampler - given an entity's (x, z) it returns the ground height under it. It picks between two models per tile, on bit 0x800 of that tile's word in the object grid (+0x8000, read at +0x8000 + tile_z*0x100 + tile_x*2):

cell 0x800model
clearBilinear nibble surface. The four corner tiles' elevation tiers through the LUT, weighted by the sub-tile position (x & 0x7F, z & 0x7F) and >> 14. Four equal corners short-circuit to the LUT value. This is flat ground and gentle terrain.
setElevation override. The height is the flat mean of the four corner tiers (sum >> 2) plus the tile's kind-2 trigger record: rec[2] * -0x20 (whole-tile step) + ((rec[3] >> shift) & 3) * -0x10 (per-sub-cell step, shift = ((x>>6) & 1) * 2 + ((z>>6) & 1) * 4). No interpolation at all. A flagged tile with no record keeps just the mean.

Ramps and staircases are the second model, and only the second model. A ramp tile's collision nibble carries no useful elevation - Rim Elm's two shore ramps sit on nibble-0 (sea-level) tiles and hold their entire elevation in the kind-2 records, whose two step fields (-32 per whole-tile count, -16 per 64-unit sub-cell) are what make a 128-unit tile a staircase rather than a plane. Interpolating a ramp's nibbles instead reads the whole ramp as sea level: an actor walking off the plateau drops the full tier height at the lip and travels under the drawn stair mesh. The kind-2 record is not an optional "fast path" layered on the bilinear branch - it replaces it.

Engine port. World::sample_field_floor_height carries both branches, reading the per-scene LUT (field_floor_height_lut), the collision grid, the object-grid cell words (field_object_cells, tested against CELL_ELEVATION_OVERRIDE) and the parsed kind-2 records (field_elevation_overrides, module world::field_elevation) - all installed at field entry. With follow_terrain_height set (on by default in play-window; --flat-y opts out) each committed locomotion step snaps the player's world_y to the sample, so the player rides slopes and stairs; field NPCs and props floor-snap through the same sampler.

The base walkable grid is streamed from disc, not authored by scripts. A runtime Write-watchpoint on the live grid during a Drake-Castle → world-map transition caught a single writer: the CD-DMA channel-3 read primitive FUN_8005D9A0, reached via FUN_8005C2C4 from the per-sector streaming poller FUN_8003EF14. That poller DMAs one 2048-byte CD sector per ready-IRQ into the field-buffer destination cursor (gp + 0x940, which held _DAT_1f8003ec + 0x4000), advancing 0x800 per sector - so the collision grid (+0x4000), object map (+0x8000) and field-pack (+0x12000) are the leading region of a multi-sector streaming read issued at scene load. (The grid changed from 2093 to 6805 wall tiles across the transition while only 6 nibble-7 tile-writes fired.)

The field VM's 0x4C (MENU_CTRL) opcode with outer-nibble 7 (op00x70..0x7F, [4C, 0x7s, col0, row0, col1, row1 (, mask)], handler 0x801e1c64) is a rectangular paint over a tile range (col ∈ [col0, col1+1), row ∈ [row0+1, row1+2) - the row bounds carry an extra +1 the column bounds do not; sub-op s = clear-walkable / block-all / clear-mask / set-mask), the sole CPU-store writer of the high-nibble wall bits. Sub-ops 0/1 ignore the mask and are 6-byte ops (PC += 6); 2/3 consume the trailing mask byte and are 7-byte ops (PC += 7). It layers story-conditional deltas on top of the disc-streamed base, not the base itself.

The collision grid is the +0x4000..+0x8000 region of the per-scene main field file. The field-asset loader FUN_8001F7C0(dest, scene_name, field_record) fills the field buffer at dest (the _DAT_1f8003ec base): the leading region (collision +0x4000, object map +0x8000) is the DATA\FIELD\<scene>.MAP file; the field-pack (+0x12000) and efect.dat (+0x12800) are separate files. Retail is the _DAT_8007b8c2 != 0 arm: it sets the CD SetLoc from the in-RAM PROT TOC at 0x801C70F0 (start_lba = toc[field_record + 2]) and streams 40 sectors (0x14000 bytes). Both transports converge on shared streaming machinery - FUN_8003E800 (generic read entry) → FUN_8003F128 (arm + CdControl(CdlSetloc)) → FUN_8003EF14 per-sector poller → FUN_8005D9A0 CD-DMA - the same writer the watchpoint caught. For the engine: load <scene>.MAP and slice bytes 0x4000..0x8000; no script execution is needed for the base walls.

Engine port. The clean-room engine does exactly this: enter_field_scene resolves the .MAP entry as the slot two below the scene's CDNAME block start (define − 2, identified by its 0x12000 extended on-disc footprint) and copies its +0x4000..+0x8000 region into the collision grid. The define − 2 rule mirrors the runtime resolution and is universal - the scene PROT clusters overlap, so the first 0x12000 entry inside a block is the next scene's map (a save-library census pinned it: the live keikoku field buffer matches its define − 2 entry with zero diffs while the in-block entry differs by thousands). One footprint caveat: the TOC-indexed payload is only the first 0x4000 bytes; the grid lives in the entry's trailing-gap sectors, so the engine reads the extended footprint. Verified byte-exact - town01's map grid equals the live RAM grid in a save state (1297 wall tiles, zero diff), and the ported player stops at real base walls (town01 + map03).

Scene-entry script. On entry the engine runs the scene's scene-entry system script (context channel 0xFB), not event-script record 0. Record 0 of a per-scene event-script container is a trigger/dispatch table, not linear bytecode, so loading it as the field-VM buffer halts the VM at pc 0 and no entry logic runs. The retail per-frame driver FUN_8003ab2c builds the system script from the MAN asset's partition 1, first record; Scene::field_man_entry_script mirrors that resolve and enter_field_scene loads the MAN slice with the VM PC at the first opcode (World::load_field_script_at), slicing from the script start so the field VM's 16-bit-wrapping relative jumps stay anchored at the slice base (matching retail buffer_base = script_start). Every field/town scene carries its MAN in a scene_asset_table: kingdom-bundle scenes use the count = 7 form, and the early standalone towns (town01 = Rim Elm, town0c, …) use a count = 6 form in their block's 2nd PROT entry (town01 = entry 4, MAN at descriptor 1). find_bundle resolves both, so the real entry script runs for all of them. The entry script's 0x4C nibble-7 wall-paint deltas are gated behind system-flag tests, so they fire only once the world's story flags are seeded to a matching scene-entry state; the base collision grid is independent of which entry script runs. Disc-gated coverage asserts the MAN-backed scenes' field VM advances past pc 0 (town01: 65, map03: 61 distinct PCs).

Story-conditional wall deltas (map03). Tracing map03's entry script pins the gate flags: TEST flag 0x6C2 (script offset 0x2c) routes into a sub-1 "block all" paint over tile (col 66, row 102), and TEST flag 0x378 (offset 0x4f) routes into a contiguous three-paint cluster (sub-0 "clear walls" at 0x56 / 0x5c / 0x62). At a fresh boot both flags are clear, so the script skips all four paints and the grid stays at its disc-loaded base - correct, since these are story-conditional terrain changes, not base walls. Seeding the matching system flags (in gameplay: loading a save whose story-flag block has them set; bank base 0x80085758 = SC offset 0x1618) makes the paints fire. The engine's nibble-7 paint matches the retail handler at 0x801e1c64: the row range is [row0+1, row1+2) and sub-0/1 paints are 6-byte ops.

Scene encounter table. The same MAN that supplies the entry script carries the scene's random-encounter table in its section 0 (FUN_8003AEB0 installs it into _DAT_801C6EA4 + 0x20). Because the count = 6 detector now resolves the standalone towns' MAN, the field scene-entry path pulls the disc-resident table for them too: Scene::field_man_encounter_table resolves the MAN through find_bundle, decodes the encounter section via encounter_man::scene_encounter_from_man, and enter_field_scene installs it (World::install_man_encounter) - the per-formation rows become EncounterEntrys keyed by row index, and the matching FormationDefs (row index → monster-id slots) merge into the formation table so a triggered encounter resolves to a concrete monster set. The MAN holds formation monster-ids but not stat blocks, so the stat catalog is installed separately; scenes with no MAN keep the synthetic-pattern EncounterRegistry fallback. Towns carry real tables too: town01's MAN declares 7 formations at a low mean trigger rate (6/256), gated by its region records. Disc-gated coverage: field_man_encounter_disc.rs.

The +0x8000 map is a per-tile object/attribute word: its low 9 bits index the +0x0000 object-record table, which FUN_8003a55c walks at scene entry to spawn the NPCs/objects occupying each tile. FUN_8003aeb0 (the field/town scene-entry map-init - note its town_mode / baria_mode debug strings) ORs the 0x400 footprint flag into these cells from the field-pack region records (+0x12000, offset/count at +0x12006 / +0x12008).

Object-record format (+0x0000, 0x20-byte stride)

FUN_8003a55c reads each record at field_buffer + idx*0x20 (the .MAP file's authored copy; the runtime region is mutated):

OffsetTypeMeaning
+0x00u16X sub-tile offset; world_x = col*128 + this + 0x40
+0x02u16Y offset added to tile floor height (heightLUT[grid_byte & 0xf])
+0x04u16Z sub-tile offset; world_z = row*128 - (this - 0x40)
+0x06i8footprint column delta to the anchor tile
+0x07i8footprint row delta to the anchor tile
+0x12u16flags; bit 0x4 = placed/active
+0x1eu8non-zero ORs actor +0x74 bit 0x40000000

This table is the static environment placement - the visible terrain segments, buildings, and props, not (only) NPC spawns. Each placed tile allocates a static-object actor (shared tick fn 0x8003BC08) whose mesh comes from the scene_asset_table TMD pack via its +0x44 chain. Validated against a live town01 save: object id 137 = Vahn's house, anchor tile (38,25)(4864, _, 3208); 46 placed objects. Each object's drawn mesh is the record's +0x10 u16 field - uniformly, for every object id (retail FUN_80020f88: actor+0x64 = record[+0x10] + DAT_8007b6f8; the id selects the record, never the mesh); ids 1/2/3 are protagonist/NPC meshes from the shared pool; anim_id only animates. A positional "field-actor band" reading (obj_idx - 5 for ids 93..=118) is falsified: Rim Elm cell (30,17) carries object id 99 whose record +0x10 = 2, and the retail GPU prim pool draws that cell's surface from env-pack mesh 2 - the quad's cba=0x7D00 / tsb=0x000C and UV set match mesh 2's primitive byte-for-byte, and its four screen vertices are exactly that cell's corners. The band rule swapped ten town meshes per Rim Elm map, dropping the terrain slab south-east of the spawn and leaving the render-pass clear colour showing through the ground. Clean-room parser legaia_asset::field_objects (parse_placements + pack_mesh_index); the engine reads it via Scene::field_object_placements, and legaia-engine play-window renders the town from it (resolve_field_placement_draws). Per-tile world Y comes from the MAN floor-height LUT (see Floor height: two models).

Two spawners, one per object. A placed record becomes an actor through exactly one of two grid sweeps, and the anchor tile's 0x400 footprint bit is the switch. FUN_8003A55C (SCUS, scene init, whole grid) resolves the record's object bind at its footprint-anchor tile (col + record[+0x06], row + record[+0x07]) and skips the record when no kind-1 trigger sits there; the bind supplies the object's interaction script and its animation id (partition-0 header [u8 n][n*2 name][u8 anim_id]actor+0x5C). FUN_801D7B50 (field overlay, sub-area window rebuild) frees the actor list and re-populates it from the window's cells with no bind lookup at all - its only extra gate is that 0x400 bit (801d7ccc: andi v0,v0,0x400 → skip), the bit FUN_8003AEB0 stamps in from the gate-0 bind triggers. The two sets are complementary on the disc (Rim Elm: 37 bound + 9 unbound = all 46), so every placed record draws - the bound ones posed by their bind's clip, the rest raw. Rim Elm's cavern shell (record 168, env mesh 72 - a round chamber with an entry corridor, ~3100 × 4000 units) is the window sweep's: read the bind as a spawn gate and the cave interior vanishes into the clear colour.

The bind poses the mesh. With actor+0x5C == 0 the actor stays at draw kind 5, which draws every TMD object with the actor's single transform - right for a single-object prop. With a nonzero id the anim tick FUN_800204F8 binds scene-ANM record anim_id - 1 into actor+0x4C and flips the actor to draw kind 1, whose walker FUN_8001B964 applies the clip's per-bone rigid transform to each TMD object (and refuses to draw unless bone count == object count). So a multi-object bound prop is posed, not stamped, and the clip's frame 0 is its rest state - Rim Elm's searchable cupboard (object 230, env mesh 15: cabinet + two hinge-authored doors, 3-bone / 30-frame door-swing clip) hangs its doors inside the cabinet and through the floor without it.

Superseded readings
  • "No on-disc wall blob; walls authored entirely in the prescript." Wrong - it came from a static search for CPU stores to +0x4000, which can't see the DMA-load path. The base grid streams from the .MAP file; the 0x4C nibble-7 paints are story-conditional deltas only.
  • "Standalone SceneEventScripts scenes have no MAN in the static bundle" (falling back to event-script record 0). A detector gap - the count = 6 table was rejected by a strict count == 7 && first_offset == 0x40 check. The MAN source was pinned by a runtime write-watchpoint on _DAT_8007b898: the dispatcher FUN_8001F05C case 3 mallocs the buffer and LZS-decodes it from the table descriptor. A related prior assumption - that towns like Rim Elm had no random encounters - fell with it.
  • "+0x8000 is a terrain-flag grid." It is the per-tile object/attribute map (low 9 bits = object-record index, high bits = per-tile flags).
  • "The +0x0000 table holds NPC / event / trigger spawns, not building meshes" (inferred from the near-zero +0x08..+0x0c fields). Those fields are not the mesh selector - they are the rotation triple (PSX 4096-per-rev; +0x0a = yaw, the Sebucus bridges' quarter-turns), copied into actor +0x24/+0x26/+0x28 for the render dispatcher's matrix builder FUN_80026988. The records are the buildings - the mesh comes from +0x10 / the pack-index band.

Environment geometry

A field/town scene's environment meshes (terrain, buildings, props) are Legaia TMDs packed inside LZS streams of the scene_asset_table PROT entry (town01 = entry 4: 121 meshes, ≈8041 verts). The clean-room SceneResources TMD pass scans each entry's LZS-decompressed sections (not just raw bytes), so these meshes land in the scene TMD pool; the field build uses SceneLoadKind::Field with upload_all_tims, matching retail's field loader (FUN_8001f7c0), which DMA-uploads every TIM - lifting the town's prim keep ratio from 24% to 95%. Per-mesh world placement + mesh selection come from the object table above (FUN_8003a55c / legaia_asset::field_objects); per-tile world Y = -floorHeightLUT[nibble] + y_off (MAN header +0x02, Scene::field_floor_height_lut). play-window renders the town from it (resolve_field_placement_draws).

Town / field parity

The controller is selected by game mode: mode 0x03 loads the field overlay (overlay_0897), which contains the single free-movement controller FUN_801d01b0. FUN_801d01b0 was runtime-pinned on a walkable field scene (map03, mode 0x03). Rim Elm - scene town01 - also runs at game mode 0x03 (see scripts/scenarios.toml, the v0_1_pre_battle_tetsu anchor), so it loads the same overlay and the same controller. The shared scene-entry init FUN_8003aeb0 corroborates this: it has an explicit town_mode debug-string branch and configures the same player actor (_DAT_8007c364: speed mult +0x72 = 0x1000, +0x6a = 8) for both towns and fields.

The overworld walk mode shares it too: the world-map-walk overlay's locomotion is byte-for-byte the same FUN_801d01b0 (same collision FUN_801cfe4c, same _DAT_1f8003ec + 0x4000 grid). The three kingdom overworld scenes (map01/map02/map03) carry real wall data in that grid (≈ 7968 / 2283 / 3837 wall sub-cells), so the overworld is bounded by the same tile-wall mechanism as towns - not a separate walkability format.

Input lock during an opening cutscene. World::step_field_locomotion is gated on current_dialog, an active tile-board, the per-actor movement-disabled flag (move_state.flags & 0x0008_0000), and an active opening-cutscene timeline (World::cutscene_timeline_active). During the town01 opening's establishing sweep the spawned cutscene timeline drives the lead actor through its own MoveTo ops, so the pad must not also walk the player out from under the cinematic camera; control returns the frame the timeline drops.

Open

NPC walkers carry a live heading (World::field_npc_headings, the player's 12-bit render_26 convention, derived from each motion-VM step's direction and retained on arrival); the play-window field renderer rotates each NPC model to it and plays the placement's scene-bundle ANM clip per frame (the same posed-rebuild path as the player's idle/walk pair).

  • The full FUN_801d5b5c post-kernel state (the touch-event handler beyond the decoded entry kernel).
  • Full per-actor field-VM channel execution with story-flag-conditioned branches (the engine loops decoded waypoint lists, and the initial-facing decode takes the fall-through branch - see NPC initial facing - rather than evaluating the prologue's 0x7x flag-TEST chain against live flags, so a later-chapter branch's facing/position is not selected).

NPC initial facing

The placement record carries no facing byte - its 4-byte header is [model, anim, tile_x, tile_z] only. A never-walked NPC's heading comes from a spawn-time prologue pre-run: the placement installer FUN_8003A1E4 ends by executing the record's leading field-VM ops one at a time through FUN_801DE840 when the first opcode is the 0x24/0x25 spawn-prologue marker, stopping at a 0x21 NOP terminator or any below-0x20 byte (body 0x8003A474..0x8003A4F8; see ghidra/scripts/funcs/8003a1e4.txt).

Two prologue ops write the actor's +0x26 render heading from the 8-direction LUT at SCUS 0x80073F04 (entry i = i * 0x200; the LUT has 16 addressable slots but only 0..=7 are direction entries):

  • 0x4C 0x51 (nibble-5 sub-1, the NPC move-to-tile op): the dispatcher writes +0x14/+0x18 from the tile bytes and +0x26 = table[b3 & 0xF] - operand byte +3's low nibble is the facing index (overlay_0897_801de840.txt, case 5 sub 1);
  • 0x38 CAM_CFG simple path (op1 & 0x7F == 0): +0x26 = table[op0 & 0xF].

The heading space itself is pinned from the locomotion's pad→facing writes (FUN_801d01b0 body 0x801d04b8..0x801d0548): retail 0 = Z−, 0x400 = X−, 0x800 = Z+, 0xC00 = X+ - the engine's render_26 convention (0 = Z+) rotated a half-turn, engine = (retail + 0x800) & 0xFFF, no axis mirror.

Town prologues route the facing leg through a story-flag 0x7x-TEST branch chain (jump when the flag is set), so the fall-through branch - the first leg in linear record order - is the fresh-game state.

The engine decodes that leg statically per placement (man_field_scripts::placement_initial_facing, skipping cross-context and park-sentinel legs), converts through facing_index_to_engine_heading, and seeds World::field_npc_headings at scene entry (World::seed_field_npc_facings) - a later walk overwrites the slot exactly as retail's per-step facing writes overwrite +0x26. Semantic pin: town01's side-by-side villager pair at tiles (29,22)/(30,22) derives LUT indices 6 (X+) and 2 (X−) - they face each other; disc-gated coverage in field_npc_initial_facing_disc.rs.

Note the facing pin also fixes what 0x4C 0x51 operand byte +3 is: bit 7 toggles the special-model flag, the low nibble is the facing-LUT index - and the raw case-5-sub-1 asm reads the byte nowhere else, so the op carries no speed operand (byte +4 is the move-anim id written to +0x5C; the trailing FUN_801D81E0 is an active-list relink via FUN_800204A4/FUN_80020454, not a bytecode builder). The old glide-speed reading of the same byte is a misattribution of the walk-kernel op 0x47's own operand encoding - see the reconcile note under NPC glide speed below.

NPC glide speed

An NPC's per-frame glide is NOT the player's +0x72 walk step (that premise is falsified: FUN_8003774C never reads +0x72). Both walk kernels encode the base step in the walk op's own operands, on the shared ladder numerator >> (2 + bits) units per frame (base steps 32 / 16 / 8 / 4 / 2 / 1 for bits 0..5 at numerator 0x80, floored at 1):

  • Field-VM yield ops (FUN_8003774C - scripted glide legs): per-frame magnitude _DAT_1f800393 × numerator / (4 << bits). bits = (op0>>5 & 4)|(op1>>6) for the axis-glide ops 0x37/0x41, b2 & 7 (high nibble = approach mode) for the walk-to-tile op 0x47. The numerator is 0x80 for 0x37/0x47 but 0x40 for 0x41 - half speed, the li a1,0x40/li a1,0x80 split at 0x80037908. _DAT_1f800393 is taken at its cold-field value 1.
  • Tail-section-1 motion streams (FUN_80038158 - the ambient town-NPC wander; see motion-vm § second VM): the directional steps 0x03/0x19/0x20 carry bits in operand byte 1's low nibble; the pad-echo step 0x06 and the AABB wander 0x18 scatter a 4-bit selector over their four operand bytes' high bits. All step 0x80 >> (2 + bits).

There is no synthesised motion bytecode for the yield ops: 0x37/0x41/0x47 are the field VM's own yield-class opcodes. The dispatcher parks the op's instruction pointer at actor +0x94 (progress cursor +0x54, HALT flag 0x400) and FUN_8003774C interprets the record bytes in place each frame, resolving the same 0x80 extended-target convention as the field VM.

The engine decodes each placement's glide speed from those real operands off the disc: man_field_scripts::placement_glide_speed tries the placement's bound tail-section-1 stream first (placement_wander_step - binding id = N0 + placement_index, default variant first), then the record's own pre-text field-VM yield ops (placement_yield_step - own-context only, with the park-sentinel/locality filters on a 0x47's target), maps the selector through World::field_npc_walk_step_speed, and stashes it in World::field_npc_glide_speeds. World::start_field_npc_motion writes that into the leg's motion-VM speed. Disc-gated field_npc_glide_speed_disc.rs pins town01's wandering villagers to their 0x18-decoded steps (binding 0x30 = slot 12, bits 3 = step 4) and the plaza nudge NPCs to their 0x41-decoded step 16.

A placement with no walk-kernel op in either carrier falls back to the facing-nibble heuristic (facing_nibble_glide_speed: the first local 4C 51 leg's byte-+3 low nibble through field_npc_glide_speed); a placement with no decodable motion leg at all (and the actor-VM sprite glide, which has no MAN motion operand) falls back to the FIELD_NPC_MOTION_SPEED stand-in (base step 8), so the default path is unchanged.

Modelling note (reconcile outcome): the raw 4C 51 handler pins its byte +3 as [bit7 special-model | facing nibble] with no speed field - retail 4C 51 is a teleport + move-anim start, and the only speed-carrying ops are the walk kernels' own operands (above). The heuristic arm therefore reads a facing nibble as the base-step selector - a stable per-NPC variation with no retail speed semantics - and fires only when no real walk-kernel op decodes.

Provenance: how the controller was pinned

FUN_801d01b0 was found with a runtime write-watchpoint on the player position fields (scripts/pcsx-redux/autorun_player_pos_watch.lua): walking in a field scene fires write hits at the four sh stores 0x801D0684 / 06E4 / 0744 / 07B4 (player Z± / X±), all inside FUN_801d01b0. Static analysis alone never surfaced it because the writes are buried in a 1964-byte function and the field overlay only loads at runtime. The cluster previously suspected to be locomotion (801db81c..801dbf9c) turned out to be the field camera system, which only reads the player position.

Engine port: movement compass + opt-in precise movement

The engine mirrors retail's camera-remapped pad in World::step_field_locomotion (decode_field_direction): the held d-pad is rotated by World::field_camera_azimuth quantised to the nearest 90° and stepped through the per-axis collision above. The faithful remap is also ported standalone: World::remap_pad_direction is func_0x800467e8's 45° eighth-turn remap over the 8-direction ring, and the 90°-quantised decode agrees with it for the axis-aligned cameras every retail field scene uses. The wall-slide resolver is ported too - World::resolve_field_slide, a pure resolver over World::field_tile_is_wall (retail's walkability probe func_0x801d56c4) with the same no-clip / pure-diagonal short-circuits, three-point block test, perpendicular sweep, and strict-sign slide-bit selection - but as a standalone kernel: the default decode_field_direction still stops at a blocked axis rather than sliding, so wiring the resolver into the live pad path is a separate step.

The azimuth feed is Camera::compass_azimuth_units() (engine-core): scripted yaw, plus the user's manual_orbit (the play-window's left-mouse drag-orbit), plus the host renderer's render_yaw_bias (the follow camera's fixed base yaw, compass sense = the negated PSX render yaw), pushed into the world each BootSession::tick. All three terms default to 0, so headless hosts keep the identity remap.

Two non-retail, opt-in knobs layer on top - play-window keybinds, persisted in legaia-options.toml:

  • Camera distance (Camera::distance; presets retail / far / farther, T cycles) - a pure framing scale on the follow camera's eye-back depth. It never feeds the simulation; the engine-core default stays retail so oracle / replay paths are bit-identical, while the windowed host defaults to far.
  • Precise movement (World::precise_movement; R toggles, default off) - swaps the quantised remap for a continuous decode (decode_field_direction_precise). The azimuth rotates the screen vector at full angular resolution, key diagonals walk true 45° vectors at normalised speed (no ×0.75 cut - the vector itself is unit length), and a deflected analog stick (InputState::lstick) passes its angle through. The step still routes through the same 2-unit per-axis collision probes (advance_with_collision_vector, Z before X per sub-step), with a sub-step remainder carried across frames so shallow angles keep their exact slope.

See also