Field free-movement locomotion
The player free-movement controller for towns, dungeons, and walkable field areas is FUN_801d01b0 in the field overlay. Each frame it turns the held pad into a camera-relative direction, advances the player position a fixed step with per-axis collision, and updates facing. This is the general locomotion path — not the tile-board minigame that shares the overlay.
How it 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.
Player actor fields
The player actor pointer is the global _DAT_8007c364. Confirmed fields:
| offset | meaning |
|---|---|
+0x10 | flags; bit 0x80000 = movement disabled (encounter pending / cutscene), bit 0x1000000 = action/interact requested |
+0x14 | world X (s16) |
+0x16 | renderer facing angle (s16) |
+0x18 | world Z (s16) |
+0x26 | heading (8-direction movement angle, set from the pad direction) |
+0x5c | run/dash state counter (> 0 switches the walk-animation select) |
+0x72 | per-actor speed multiplier (fixed-point, >> 12) |
+0x94 | encounter-record pointer |
+0x98 | interaction-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's0x20-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
- Disabled gate. If
player.flags & 0x80000is set, skip all movement (an encounter is queued or a cutscene owns the player). - Action button. An edge-pad action bit (
_DAT_8007b874 & 4) plays the confirm SFX and raisesplayer.flags |= 0x1000000(talk / examine), short-circuiting movement that frame. - Direction decode.
func_0x800467e8(&_DAT_8007b850)rewrites the held pad in place into a camera-relative mask.FUN_80046494(player)reads that mask and returns the movement direction in bits& 0xf000, resolving diagonals. The heading+0x26is set to one of eight angle constants.mask bit (post-remap) axis delta 0x1000Z + 0x4000Z − 0x2000X + 0x8000X − - Speed.
speed = ((base_step * player[+0x72]) >> 12) * DAT_1f800393, wherebase_stepis8walking (other values in run / forced states) andDAT_1f800393is the per-frame delta scalar. Modifiers: terrain-slow (speed >>= 1on a0x4000-flagged tile when scene byte_DAT_801c6ea4[+0x61] == 1) and diagonal normalise (speed -= speed >> 2). - Step loop. Advances 2 units per iteration until
speedunits 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 -= 2collideisFUN_801cfe4c;dir-codes are0 = 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. Step SFXfunc_0x80035b50(0x20); a fully-blocked move plays the bonkfunc_0x80035bd0(0x23).
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 of0x40bytes, up to0x80rows. - 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_maskselects the relevant quadrant. A set bit = wall. So one map byte covers a128×128world tile, divided into four64×64sub-cells. - Direction-specific probe offsets come from tables
DAT_801f21b4/DAT_801f2214(16-byte stride per direction); three nearby points are probed 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.
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 base | content | filled by |
|---|---|---|
+0x0000 | object / actor records (0x20-byte stride; up to 512) | scene loader / field VM |
+0x4000 | collision + floor grid — 1 byte/tile, 0x80-byte rows: high nibble = 4 sub-cell wall bits, low nibble = floor-elevation tier | base: the .MAP file's +0x4000 region (FUN_8001f7c0); field-VM 0x4C nibble-7 ops apply conditional deltas |
+0x8000 | per-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 |
+0x12000 | field-pack region; _DAT_8007b8d0 = base + 0x12800 | FUN_8001f7c0 (scene asset loader) |
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).
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 (op0 ∈ 0x70..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. (An earlier reading here claimed there was no on-disc wall blob and walls were authored entirely in the prescript — that was wrong; it came from a static search for CPU stores to +0x4000, which can't see the DMA-load path.)
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 opens .MAP by ISO9660 name; the debug path (_DAT_8007b8c2 != 0) 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 finds the .MAP entry (the unique CDNAME-block entry whose extended on-disc footprint is 0x12000 bytes) and copies its +0x4000..+0x8000 region into the collision grid. 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. (An earlier reading held that standalone SceneEventScripts scenes "had no MAN in the static bundle" and fell back to event-script record 0; that was 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.) 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. Reaching them corrected two latent bugs in the engine's nibble-7 paint: the row range is [row0+1, row1+2) (was one row too far north), and sub-0/1 paints are 6-byte ops (the engine advanced every sub-op by 7, collapsing the three-paint cluster into one). Both now match the retail handler at 0x801e1c64.
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. This corrects a prior assumption that towns like Rim Elm (town01) had no random encounters: 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 not a terrain-flag grid (an earlier reading). It 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):
| Offset | Type | Meaning |
|---|---|---|
+0x00 | u16 | X sub-tile offset; world_x = col*128 + this + 0x40 |
+0x02 | u16 | Y offset added to tile floor height (heightLUT[grid_byte & 0xf]) |
+0x04 | u16 | Z sub-tile offset; world_z = row*128 - (this - 0x40) |
+0x06 | i8 | footprint column delta to the anchor tile |
+0x07 | i8 | footprint row delta to the anchor tile |
+0x12 | u16 | flags; bit 0x4 = placed/active |
+0x1e | u8 | non-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. (An earlier reading concluded this held "NPC / event / trigger spawns, not building meshes" from the near-zero +0x08..+0x0c fields — that was wrong: those fields are not the mesh selector, but the records are the buildings. 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 comes from the scene_asset_table pack (byte-verified): pack_index = obj_idx - 5 for the field-actor band 93..=118, else the record's +0x10 u16 field (ids 1/2/3 are protagonist/NPC meshes from the shared pool); anim_id only animates. 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). Remaining refinement: per-tile world Y (the MAN floor-height LUT; currently flat ground).
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
FUN_801cfc40(the finer actor/edge collision probe) is not fully decoded.