diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5c21bf6..0d0bdb3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,10 +23,10 @@ concurrency: jobs: ci: - uses: THPS64Recomp/THPS64Host/.github/workflows/game-ci.yml@v0.2.0 + uses: THPS64Recomp/THPS64Host/.github/workflows/game-ci.yml@v0.3.0 with: game: thps2 - host-tag: v0.2.0 + host-tag: v0.3.0 secrets: # Only while the repositories are private (RELEASING.md); empty, and unused, once they are public. THPS64_READ_TOKEN: ${{ secrets.THPS64_READ_TOKEN }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9f80c62..c23217d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -33,12 +33,12 @@ concurrency: jobs: release: - uses: THPS64Recomp/THPS64Host/.github/workflows/game-release.yml@v0.2.0 + uses: THPS64Recomp/THPS64Host/.github/workflows/game-release.yml@v0.3.0 permissions: contents: write # the draft release; the called workflow narrows it per job with: game: thps2 - host-tag: v0.2.0 + host-tag: v0.3.0 version: ${{ inputs.version }} arch: ${{ inputs.arch }} replace-assets: ${{ inputs.replace-assets }} diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ed7cd8..25ce675 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,23 @@ All notable changes to THPS2 64: Recompiled are listed here. The format is based ## [Unreleased] +### Changed +- Built on THPS64Host v0.3.0. +- Draw distance at Full draws the areas the courses hide by zone again, and only those: each course's trigger file is read at + its start to tell the zones the game hides for distance from the ones that hold course state (The Hangar's crashed ceiling + and its floor's light overlays), which stay as the game sets them. Rooms (a zone the course hides from its own doors, one + each in School II and Skatestreet) are left to the game. +- Tests: test rides claim a controller before steering, and an opt-in session records and replays a complete ride. Recorded + rides use a consistent replay step for the game's coupled timing values. + +### Fixed +- School II at Full draw distance: patches of the sunlit ground no longer flash flat. The renderer was splitting those frames + into many separate passes; School II, NY City, Bullring and Philadelphia draw them in one pass again, which also takes about + 1 ms less per frame. +- Philadelphia: trees at the edge of the view no longer smear and buildings no longer open holes as you turn, with frame + interpolation on. +- Skating tests read the actual physics dispatch state, so airborne and rail observations describe the skater correctly. + ## [0.2.0-preview] - 2026-10-27 The second early preview, built on THPS64Host v0.2.0: split screen, Picture Border, rebinding, draw distance and the diff --git a/decomp/common b/decomp/common index 0792b98..eec0ae7 160000 --- a/decomp/common +++ b/decomp/common @@ -1 +1 @@ -Subproject commit 0792b989655155808a9730d0dae1b80588daaaab +Subproject commit eec0ae70f44c6b1c0eb5b5c1c87931c2139b4023 diff --git a/decomp/thps2/recomp-host/CMakeLists.txt b/decomp/thps2/recomp-host/CMakeLists.txt index 2fa52b4..fc085cb 100644 --- a/decomp/thps2/recomp-host/CMakeLists.txt +++ b/decomp/thps2/recomp-host/CMakeLists.txt @@ -18,4 +18,6 @@ set(THPS_HOST_ROOT "${THPS_HOST_ROOT}" CACHE PATH "The shared host tree (THPS64H if (NOT EXISTS "${THPS_HOST_ROOT}/host/cmake/thps_host.cmake") message(FATAL_ERROR "The shared host (THPS64Host) was not found: run `git submodule update --init`, or pass -DTHPS_HOST_ROOT=") endif() +# The THPS2/3 engine's draw distance is shared in the Host (game/draw_distance.cpp here has only the profile). +set(THPS_GAME_EXTRA_SOURCES "${THPS_HOST_ROOT}/host/src/draw_distance_thps23.cpp") include("${THPS_HOST_ROOT}/host/cmake/thps_host.cmake") diff --git a/decomp/thps2/recomp-host/game/draw_distance.cpp b/decomp/thps2/recomp-host/game/draw_distance.cpp index e18e9b7..13de56f 100644 --- a/decomp/thps2/recomp-host/game/draw_distance.cpp +++ b/decomp/thps2/recomp-host/game/draw_distance.cpp @@ -1,838 +1,18 @@ -// THPS2's side of the extended draw distance (the setting and governor: common/host/src/draw_distance.cpp; THPS1's side, with the -// notes on the shared engine, is game/draw_distance.cpp in THPS1-64Recomp). -// -// The same engine as THPS1. The gameplay camera's far plane is one float, gFarPlane (US 0x800E4A80: the Hangar's is 407.75 = -// 6524 / 16), set with the level's fog (func_80023CFC) and handed to View_SetFrustum every frame (twice: near 10 and 10.5); -// the fog is the same constant gSPFogPosition(996, 1000). -// -// The buffers. Two top-level display lists of gDlBufSize bytes (0x12000; pointers gDlBufs) and two pools per frame, each with -// its size in a word next to its pointers: matrices (gMtxPools, gMtxCap = 420 x 64 bytes) and vertices (gVtxPools, gVtxCap = -// 500 x 16 bytes). Unlike THPS1's, these allocators clamp: past the cap they hand out the pool's last slots again (shared -// transforms / vertices for one frame, not memory damage). The display list has no cap. The game writes the list's length into -// the gfx task's data_size; pool use is read from the list's G_MTX / G_VTX commands that point into the task's pools. -// -// The renderer's lists (as in THPS1, which has the same mesh queue). Between a pass's begin (0x800C50B0) and end (0x800C50D0) the meshes of -// what is drawn are batched (16 instances of one model, flushed by 0x800C5A64) into two fixed lists of the renderer (gRenderer): the mesh -// queue, 512 entries of 16 bytes (+0x1CC, count +0x21CC: mesh, matrix, next, linked per material) and a depth-sorted list of translucent -// meshes, 96 entries of 24 bytes (+0x21D8, count +0x2AD8). The flush drops what does not fit, without a word (`li v0,512` / `li v0,96`). -// The pass end draws both (Mesh_QueueEmit) and empties them. The course items (World_DrawItems) are one pass, the scenery (the 20000 -// frustum) and the moving objects are passes of their own. Mesh_QueueEmit's hook notes both counts, read only, so they are buffers for the -// governor too. -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include "draw_distance.h" -#include "recomp.h" -#include "thps_hooks.h" -#include "host.h" -#include "versions.h" - -namespace { - -uint32_t word(const uint8_t* rdram, uint32_t vaddr) { return *reinterpret_cast(rdram + (vaddr & 0x3FFFFFC)); } -float f32(const uint8_t* rdram, uint32_t vaddr) { - uint32_t w = word(rdram, vaddr); - float f; - std::memcpy(&f, &w, 4); - return f; -} - -constexpr uint32_t kQueueCount = 0x21CC, kQueueEntries = 512, kSortCount = 0x2AD8, kSortEntries = 96; -std::atomic g_runs_hw{0}, g_queue_hw{0}, g_sort_hw{0}; // most entries one pass used since the last gfx task (game thread writes, gfx thread takes) -void max_into(std::atomic& a, uint32_t v) { - uint32_t m = a.load(); - while (v > m && !a.compare_exchange_weak(m, v)) {} -} - -} // namespace - -// Mesh_QueueEmit(a0 = renderer) entry, game thread: the pass's lists are complete here (its end has just flushed the last batch). -extern "C" void thps_mesh_queue_emit(uint8_t* rdram, recomp_context* ctx) { - thps::dd::HookTimer timer; - const uint32_t r = uint32_t(ctx->r4); - if (r != word(rdram, thps::sym().gRenderer)) return; // not the renderer this was written for - max_into(g_queue_hw, std::min(word(rdram, r + kQueueCount), kQueueEntries)); - max_into(g_sort_hw, std::min(word(rdram, r + kSortCount), kSortEntries)); -} - -// The world view. Besides the gameplay camera's own frustum calls (every frame), the camera code keeps a second view (0x800DB290 in US) -// for the world: func_80023800 sets its perspective only when gFarPlane differs from the copy it cached (gWorldFarCache), i.e. once per -// level. A factor chosen after the level loaded therefore never reached it (the first measurements of this game, made that way, -// showed "no change"; with the setting from start-up the Hangar start draws more). So when the factor in use changes, the cached copy -// is cleared, and the camera code sets the world view's perspective again in the next frame, through View_SetFrustum and this hook. -bool thps::dd::supported() { return true; } -namespace thps::dd { void dd_game_sync_pools(uint8_t* rdram); } // the matrix pools' cap in force (below) - -// As THPS1: a call is the gameplay camera's (or the world view's) when its far is gFarPlane, or the far returned last; the answer is -// gFarPlane x k (k = 1 gives the game's value back). Game thread only. -float thps::dd::dd_game_far(uint8_t* rdram, float f, float k_in_use) { - // The far plane follows the setting (projection_factor: the setting, lowered only by a back-off), not the factor in use: the world view takes a - // new far only when the camera code sets it (a course start, camera changes), and the factor in use is applied to the extra items - // in thps_world_draw_items instead. - (void)k_in_use; - const float k = projection_factor(); - static float last_out = 0.0f, applied_k = 1.0f; - const auto& s = thps::sym(); - if (k != applied_k) { - applied_k = k; - *reinterpret_cast(rdram + (s.gWorldFarCache & 0x3FFFFFC)) = 0; // 0.0f: never a far plane, so the next frame refreshes the world view - } - dd_game_sync_pools(rdram); // the matrix pools' cap in force follows the setting (below) - const float game_far = f32(rdram, s.gFarPlane); - if (!(game_far > 0.0f) || (f != game_far && f != last_out)) return f; - note_game_far(game_far); - const float out = std::min(game_far * k, std::max(game_far, kFullFarPlane)); // full mode: as far as kFullFarPlane - last_out = out == game_far ? 0.0f : out; - note_effective(out / game_far); - return out; -} - -// Display-list measurements, debug only (off in normal runs; read once at start-up). THPS_DD_DLPROBE=1: the bytes each part of a frame -// writes into the display list (gDlHead - the frame's buffer): before World_DrawItems, each extra pass (with its pass end and begin), the -// game's own pass, and the rest of the frame after the extra passes (the game's pass, the scenery, the moving objects, the HUD, the other -// player's view), per frame; `dd dlprobe [reset]` reports their medians, 99th percentiles and maxima. THPS_DD_CANARY=1: each display -// list the course set-up allocates gets kGuard bytes of a pattern after its end, checked at every gfx task (a changed word = an overrun -// past gDlBufSize; counted and logged once per list; the pattern is written only at the allocation, so a guard can never damage memory -// that has a new owner since). -namespace { -bool probe_on() { static const bool on = std::getenv("THPS_DD_DLPROBE") != nullptr; return on; } -bool canary_on() { static const bool on = std::getenv("THPS_DD_CANARY") != nullptr; return on; } -constexpr uint32_t kGuard = 64, kGuardWord = 0xDDC0FFEEu; -void fill_guard(uint8_t* rdram, uint32_t buf, uint32_t size) { - for (uint32_t k = 0; k < kGuard; k += 4) *reinterpret_cast(rdram + ((buf + size + k) & 0x3FFFFFC)) = kGuardWord; -} -// Bytes the frame being built has written into its display list so far (three word reads). -uint32_t dl_used(const uint8_t* rdram) { - const auto& s = thps::sym(); - const uint32_t buf = word(rdram, s.gDlBufs + 4 * (word(rdram, s.gDlFrame) & 1)), head = word(rdram, s.gDlHead); - return head >= buf && head - buf < (64u << 20) ? head - buf : 0; -} -void probe_view(uint8_t* rdram, uint32_t player, uint32_t entry, uint32_t after_extras); -struct Samples { - std::vector v; - void add(uint32_t x) { if (v.size() < 500000) v.push_back(x); } - std::string str(const char* name) const { - if (v.empty()) return std::string(" ") + name + "=-"; - std::vector c = v; - std::sort(c.begin(), c.end()); - char b[96]; - std::snprintf(b, sizeof b, " %s=%u/%u/%u(n%zu)", name, c[c.size() / 2], c[std::min(c.size() - 1, c.size() * 99 / 100)], c.back(), c.size()); - return b; - } -}; -struct ProbeFrame { uint32_t after_extras[2] = {}, extra_bytes[2] = {}; uint8_t views = 0; }; -struct Probe { - Samples entry, pass, per_item, own, rest_1p, rest_2p_v0, rest_2p_v1, total_1p, total_2p, extras_1p; - uint32_t frames = 0, canary_bad = 0; - ProbeFrame frame[2]; // per buffer: the game thread fills it while it builds that frame, the gfx thread takes it at the task -}; -std::mutex g_probe_mutex; -Probe g_probe; -std::atomic g_direct_calls{0}; // World_DrawItems calls for the course items outside a pass (drawn at once, not in passes) -std::atomic g_dl_guarded[2] = {0, 0}; // THPS_DD_CANARY: the display lists that have a guard after them (their addresses) -std::atomic g_dl_guard_at[2] = {0, 0}; // and the size the guard follows (the front end's set-up reuses the addresses with 72 KiB lists) -} // namespace - -namespace { -// THPS_DD_DLPROBE, game thread: one World_DrawItems call (a view; player 2 = the second view of a two-player frame) wrote `entry` bytes -// before it, and its extra passes ended at `after_extras`. -void probe_view(uint8_t* rdram, uint32_t player, uint32_t entry, uint32_t after_extras) { - std::lock_guard lock{g_probe_mutex}; - ProbeFrame& f = g_probe.frame[word(rdram, thps::sym().gDlFrame) & 1]; - const uint32_t v = player ? 1 : 0; - f.after_extras[v] = after_extras, f.extra_bytes[v] = after_extras - std::min(after_extras, entry), f.views |= uint8_t(1u << v); - g_probe.entry.add(entry); -} -} // namespace - -std::string thps::dd::dd_game_dlprobe(uint8_t*, bool reset) { - std::lock_guard lock{g_probe_mutex}; - char b[160]; - std::snprintf(b, sizeof b, "probe %d canary %d frames %u canary_bad %u direct %u", int(probe_on()), int(canary_on()), g_probe.frames, g_probe.canary_bad, g_direct_calls.load()); - const Probe& p = g_probe; - std::string r = b + p.entry.str("entry") + p.pass.str("pass") + p.per_item.str("per_item") + p.own.str("own") + p.extras_1p.str("extras_1p") + - p.rest_1p.str("rest_1p") + p.total_1p.str("total_1p") + p.rest_2p_v0.str("rest_2p_v0") + p.rest_2p_v1.str("rest_2p_v1") + p.total_2p.str("total_2p"); - if (reset) { - const uint32_t bad = g_probe.canary_bad; - g_probe = Probe{}; - g_probe.canary_bad = bad; - } - return r; -} - -bool thps::dd::dd_game_measure(uint8_t* rdram, uint32_t data_ptr, uint32_t data_size, BufferUse& u) { - const auto& s = thps::sym(); - const uint32_t phys = data_ptr & 0x3FFFFFF; - int idx = -1; - for (int i = 0; i < 2; i++) - if ((word(rdram, s.gDlBufs + 4 * i) & 0x3FFFFFF) == phys) idx = i; - if (idx < 0) return false; - u.dl_cap = word(rdram, s.gDlBufSize); - u.dl_bytes = data_size; - u.mtx_cap = word(rdram, s.gMtxCap); - u.vtx_cap = word(rdram, s.gVtxCap); - if (u.mtx_cap > 4096 || u.vtx_cap > 65536) return false; // not set up yet - u.queue_used = g_queue_hw.exchange(0), u.queue_cap = kQueueEntries; - u.blend_used = g_sort_hw.exchange(0), u.blend_cap = kSortEntries; - u.runs = g_runs_hw.exchange(0); - const uint32_t mpool = word(rdram, s.gMtxPools + 4 * idx) & 0x3FFFFFF, vpool = word(rdram, s.gVtxPools + 4 * idx) & 0x3FFFFFF; - const uint32_t end = phys + std::min(data_size, u.dl_cap); - for (uint32_t pc = phys; pc + 8 <= end; pc += 8) { - const uint32_t w0 = word(rdram, pc), op = w0 >> 24; - if (op == 0xDA) { // G_MTX - const uint32_t m = word(rdram, pc + 4) & 0x3FFFFFF; - if (m >= mpool && m < mpool + u.mtx_cap * 64) u.mtx_used = std::max(u.mtx_used, (m - mpool) / 64 + 1); - } else if (op == 0x01) { // G_VTX: n vertices from w1 - const uint32_t v = word(rdram, pc + 4) & 0x3FFFFFF, n = (w0 >> 12) & 0xFF; - if (v >= vpool && v < vpool + u.vtx_cap * 16) u.vtx_used = std::max(u.vtx_used, (v - vpool) / 16 + n); - } - } - // The allocators clamp at the cap (they then hand out the last slots again): a pool counts as run out when its top slot is used. - u.mtx_capped = (u.mtx_cap && u.mtx_used >= u.mtx_cap) || (u.vtx_cap && u.vtx_used >= u.vtx_cap); - if (canary_on()) { // the guard after this buffer (only lists the course set-up allocated have one) - const uint32_t buf = word(rdram, s.gDlBufs + 4 * idx); - bool bad = false; - if (g_dl_guarded[idx].load() == buf && g_dl_guard_at[idx].load() == u.dl_cap) - for (uint32_t k = 0; k < kGuard; k += 4) bad |= word(rdram, buf + u.dl_cap + k) != kGuardWord; - if (bad) { - std::lock_guard lock{g_probe_mutex}; - if (g_probe.canary_bad++ < 10) { - std::printf("[thps] draw distance: WARNING display list %d (0x%08X, %u bytes) written past its end: task %u bytes\n", idx, buf, u.dl_cap, data_size); - std::fflush(stdout); - } - g_dl_guarded[idx] = 0; // counted once per list; never written again (the memory may be another owner's by now) - } - } - if (probe_on()) { // the rest of the frame after the extra passes of each view (the later views' extra passes not counted) - std::lock_guard lock{g_probe_mutex}; - ProbeFrame f = g_probe.frame[idx]; - g_probe.frame[idx] = ProbeFrame{}; - g_probe.frames++; - if (f.views == 1) { - g_probe.rest_1p.add(data_size - std::min(data_size, f.after_extras[0])); - g_probe.total_1p.add(data_size); - g_probe.extras_1p.add(f.extra_bytes[0]); - } else if (f.views == 3) { - g_probe.rest_2p_v0.add(data_size - std::min(data_size, f.after_extras[0] + f.extra_bytes[1])); - g_probe.rest_2p_v1.add(data_size - std::min(data_size, f.after_extras[1])); - g_probe.total_2p.add(data_size); - } - } - return true; -} - -// Which hidden bits the zone culling set. The trigger scripts hide items in two ways: World_SetVisibilityInBox (by box, as the skater moves: -// draw-cost culling) and per-item commands (by name, on events: secret areas, things revealed by a gap or a goal). Only the first kind may -// be undone for "full". The box hook below runs the game's function and records, per item, the hidden bits that call changed (set -> -// noted, cleared -> forgotten); any other writer leaves the record alone, so a bit set by an event command is never in it. The record is -// per course instance: a new item list (pointer or count) or a new course set-up (thps::dd::course_generation: Gfx_SetupCourse, also at a -// RETRY, which can rebuild the list at the same address; review 2026-10-26, finding 7) starts it again: empty, except for what the zone culling did and the items an event -// named since the course items were last drawn (the new course's set-up scripts, whether they ran before the set-up or after it). Game -// thread only. -namespace { -struct ZoneRecord { - uint32_t arr = 0, n = 0; - uint64_t gen = 0; // the course set-up it belongs to - std::vector bits; // per item: hidden bits (of 0x41) the zone culling set and nothing has cleared since - std::vector after; // per item: the item's hidden bits right after the zone culling last changed them - std::vector event; // per item: named by World_FindItem in this course (a script / goal / gap command): never unhidden -} g_zone; -std::vector g_before; -std::vector g_named_since_draw; // items World_FindItem named since the last World_DrawItems of the course items -std::vector g_zoned_since_draw; // and the indices of the items whose hidden bits the zone culling changed since then -uint16_t* flag_ptr(uint8_t* rdram, uint32_t arr, uint32_t i) { - return reinterpret_cast(rdram + ((word(rdram, word(rdram, arr + 4 * i) + 4) & 0x3FFFFFE) ^ 2)); -} -void zone_sync(uint8_t* rdram, uint32_t list) { - const uint32_t n = word(rdram, list + 16), arr = word(rdram, list + 12); - const uint64_t gen = thps::dd::course_generation(); - if (n != g_zone.n || arr != g_zone.arr || gen != g_zone.gen) { - const size_t m = n <= 8192 ? n : 0; - ZoneRecord old = std::move(g_zone); - g_zone = ZoneRecord{arr, n, gen, std::vector(m, 0), std::vector(m, 0), std::vector(m, 0)}; - // The same list, a new course instance: the zone culling done since the last draw (the new run's, while it loaded) is kept. Event - // ownership comes only from the names below, never from the old record. - if (arr == old.arr && n == old.n) - for (uint32_t i : g_zoned_since_draw) - if (i < m && i < old.bits.size()) g_zone.bits[i] = old.bits[i], g_zone.after[i] = old.after[i]; - for (uint32_t i = 0; i < m && !g_named_since_draw.empty(); i++) - if (std::find(g_named_since_draw.begin(), g_named_since_draw.end(), word(rdram, arr + 4 * i)) != g_named_since_draw.end()) g_zone.event[i] = 1; - } -} -} // namespace - -extern "C" int thps_world_set_visibility_in_box(uint8_t* rdram, recomp_context* ctx) { - thread_local bool inside = false; - if (inside) return 0; - const uint32_t list = thps::sym().gWorldItems; - zone_sync(rdram, list); - const uint32_t n = uint32_t(g_zone.bits.size()), arr = g_zone.arr; - g_before.resize(n); - for (uint32_t i = 0; i < n; i++) g_before[i] = *flag_ptr(rdram, arr, i); - inside = true; - thps::call_guest(thps::sym().World_SetVisibilityInBox, rdram, ctx); - inside = false; - for (uint32_t i = 0; i < n; i++) { - const uint16_t after = *flag_ptr(rdram, arr, i), changed = uint16_t((g_before[i] ^ after) & 0x41); - if (changed) { - g_zone.bits[i] = uint8_t((g_zone.bits[i] & ~changed) | (after & changed)); - if (g_zoned_since_draw.size() < 65536) g_zoned_since_draw.push_back(i); - g_zone.after[i] = uint8_t(after & 0x41); - } - } - return 1; -} - -// Event ownership. Whatever looks an item up by name (the scripts' per-item show / hide / kill commands, goal and gap code) owns it from -// then on, for this course: "full" never unhides it, even if the zone culling had also hidden it. Conservative on purpose: a secret -// revealed or hidden by an event stays under the game's own control. -extern "C" int thps_world_find_item(uint8_t* rdram, recomp_context* ctx) { - thread_local bool inside = false; - if (inside) return 0; - const uint32_t list = uint32_t(ctx->r4); - inside = true; - thps::call_guest(thps::sym().World_FindItem, rdram, ctx); - inside = false; - const uint32_t item = uint32_t(ctx->r2); - if (item && list == thps::sym().gWorldItems) { - if (g_named_since_draw.size() < 8192) g_named_since_draw.push_back(item); - zone_sync(rdram, list); - for (uint32_t i = 0; i < g_zone.event.size(); i++) - if (word(rdram, g_zone.arr + 4 * i) == item) { g_zone.event[i] = 1; break; } - } - return 1; -} - -// Zone visibility (draw call). The course's items (World_DrawItems' list: count at +16, pointers at +12; each item's flag halfword through the -// pointer at item +4) carry a "hidden" bit per player (1, player 2: 0x40) that the trigger scripts set and clear by box as the skater -// moves (the command at 0x800A53F0 calls 0x80033FAC in THPS2 US), so most of a course is not drawn from most places: at the Hangar -// start 144 of 470 items are hidden. With "full" the hook clears those bits, runs the function, and puts every flag back exactly, so -// collision and the trigger state never see the change. -// -// Off since v0.2 (playtest 2026-10-26): "full" no longer clears them. The box zones do more than culling here: the Hangar swaps its -// floor's light overlays by box and keeps the crashed ceiling hidden by box until its event, so clearing every box-hidden bit stacked -// the overlays (flickering squares on the floor) and showed the ceiling early. A sweep of all eight courses found almost nothing the -// unhiding added to the picture (New York: under 0.25 % of the pixels). Telling culling boxes from state boxes needs the trigger that -// issued each call (v0.3). The record below is kept, and `dd showall all` still clears every hidden bit for exploration. -// -// Runs of at most kChunk items. World_DrawItems is one pass of the renderer: its mesh queue (512) and sorted list (96) must hold every -// mesh of every item drawn, and what does not fit is dropped (in course-item order, so near items can be the ones lost). The original -// game stays far below that (School II, Marseille: up to ~210 entries and ~55 sorted); with a longer far plane and the zones unhidden the -// first frames of School II and Marseille filled both lists and dropped near items, and then the governor held the course at x1. So -// when the call draws more than the game would (a longer far plane, or zone-hidden items unhidden), the items only the setting adds are -// drawn first, farthest first, in runs of at most kChunk visible items, each run its own pass (the pass end draws and empties the lists, -// 0x800C50D0, then the next pass begins, 0x800C50B0). The last run is exactly what the game itself draws, as one pass, so it always fits. -// Far before near keeps translucent meshes over what is behind them. An item is left out of a run by its +0x44 float, which the loop -// tests right after the sphere test (negative = not drawn): set to -1 for that run only and put back. Only when the call runs inside a -// pass (the renderer's batch flag +0x2AE0; World_DrawItems' other caller, 0x8006F864, draws without one). Visibility here is the game's -// own test (0x800C364C: the view's five planes at +0x1D0 and the far distance at +0x174, camera at +0x90; item centre +0x34, radius +0x40). -// -// The display-list budget (a backstop; with the 144 KiB lists it is not expected to act). Nothing in the game stops a display list at -// its end, and the extra passes come before the parts of the frame that must be drawn: the game's own pass, the scenery, the moving -// objects, the HUD and, in two-player games, the other view. So before each extra pass the hook reads how much of the frame's list is -// written (gDlHead - gDlBufs[gDlFrame]: three words) and draws the next passes only while used + kPassBound + the rest's bound + kMargin -// fits the buffer. When it does not fit them all, it keeps the nearest extra items it can afford and leaves the farthest out (the -// remaining ones are still drawn far to near); the cut is counted (`dd`: cuts, cut_items) and logged with --verbose. The bounds are -// measured maxima with room to spare (2026-10-23, THPS_DD_DLPROBE at Full with 256 KiB lists): see kPassBound / kRestBound. -// The nearest pass keeps its bound reserved from the first extra pass on (review 2026-10-26, finding 9): the passes are drawn far to near, -// and until then the passes after the next were all counted at their running average, so an expensive far pass could leave the room short -// for the nearest items. Now the next pass and the nearest one count at their bound and only those between at the average; a shortfall -// drops more of the far ones. The nearest pass is cut (late_cuts) only if a pass took more than kPassBound. (Every pass at its bound, the -// first version of this fix, cut School II's far items at the default budget with the list less than half full.) -namespace { -constexpr uint32_t kBatchOn = 0x2AE0; -constexpr uint32_t kChunk = 48; -constexpr uint32_t kPassBound = 0x2000; // one extra pass (48 items, its pass end and begin): measured at most 6,264 bytes (THPS3 Rio) -constexpr uint32_t kItemBound = 0x200; // one extra item drawn outside a pass: at most 336 bytes per item in the passes -constexpr uint32_t kRestBound = 0xC000; // the rest of the frame after the extra passes: at most 32,144 (THPS3 Rio; p99 13-20 KB); - // two players, after the first view's (the second view's own part included): 18,088 -constexpr uint32_t kViewBound = 0x2000; // and per view still to come (two players: the second view's own part, about 5.5 KB) -constexpr uint32_t kMargin = 0x1000; -float g_pass_avg = 4096.0f; // the bytes an extra pass has been taking (a running average; game thread) -// How many of `wanted` more extra items (in passes of kChunk, or one by one outside a pass) the frame's display list can still take, with -// `used` bytes written. The next pass and the nearest (last) one must fit with their bound (kPassBound); the ones between are counted at -// what passes have been taking (g_pass_avg, at most the bound), since each of them is checked against its bound again before it is drawn. -size_t affordable(const uint8_t* rdram, uint32_t used, size_t wanted, bool in_passes, bool view_follows) { - const thps::dd::DlBudget o = thps::dd::dl_budget(); - const uint32_t buf = word(rdram, thps::sym().gDlBufSize), cap = o.cap ? std::min(o.cap, buf) : buf; - const uint64_t need = uint64_t(used) + (o.rest ? o.rest : kRestBound) + (view_follows ? kViewBound : 0) + (o.margin ? o.margin : kMargin); - if (need >= cap) return 0; - const uint64_t room = cap - need; - if (!in_passes) return size_t(std::min(room / kItemBound, wanted)); - const uint64_t bound = o.pass ? o.pass : kPassBound; - if (room < bound) return 0; - if (wanted <= kChunk || room < 2 * bound) return std::min(wanted, kChunk); // one pass: the nearest - const uint64_t est = uint64_t(std::clamp(g_pass_avg, 1024.0f, float(bound))); - return size_t(std::min((2 + (room - 2 * bound) / est) * kChunk, wanted)); -} -float fword(const uint8_t* rdram, uint32_t a) { return f32(rdram, a); } -} // namespace - -extern "C" int thps_world_draw_items(uint8_t* rdram, recomp_context* ctx) { - thread_local bool inside = false; - if (inside) return 0; - thps::dd::HookTimer timer; // ftime: this hook's own time (the game's drawing excluded) - const auto& sy = thps::sym(); - const uint32_t list = uint32_t(ctx->r4), view = uint32_t(ctx->r5), n = word(rdram, list + 16), arr = word(rdram, list + 12); - if (n == 0 || n > 8192 || list != sy.gWorldItems) return 0; - zone_sync(rdram, list); // a new course instance takes the items named since the last draw (above), then that list starts again - g_named_since_draw.clear(), g_zoned_since_draw.clear(); - const bool unhide = thps::dd::unhide_everything(); // zone unhiding is off (above); `dd showall all` only - // The view's far (+0x174) is the world view's, which the camera code sets at a course start and on camera changes, with the far plane - // of the setting (dd_game_far: projection_factor), not every frame. The governor's factor in use (lowered by its - // back-offs) is applied to the extra items instead (hold_far below). - const float game_far = f32(rdram, sy.gFarPlane), view_far = fword(rdram, view + 0x174); - const uint32_t probe_entry = probe_on() ? dl_used(rdram) : 0; - if (!unhide && !(game_far > 0.0f && view_far > game_far * 1.001f)) { // the game's own picture: one run, as the game does - if (probe_on()) probe_view(rdram, uint32_t(ctx->r6), probe_entry, probe_entry); - return 0; - } - const uint16_t mask = uint16_t((ctx->r6 ? 0x40 : 0x01) | 0x1000); // the loop's own skip test (player 2: 0x40; scenery 0x1000) - zone_sync(rdram, list); - static std::vector saved; // flag halfword address, value (game thread only) - static std::vector before; // each item's flags before any unhiding: what the game itself would draw - saved.clear(); - before.assign(n, 0); - uint32_t hidden = 0; - for (uint32_t i = 0; i < n; i++) { - const uint32_t fl = word(rdram, word(rdram, arr + 4 * i) + 4) & 0x3FFFFFE; - uint16_t* h = reinterpret_cast(rdram + (fl ^ 2)); - before[i] = *h; - if (!unhide || i >= g_zone.bits.size()) continue; - hidden += (*h & 0x41) != 0; - // A record goes stale as soon as anything else changed the bits since the zone culling did (an event hid or showed the item: - // the game's own state governs from then on), and an item named by an event is never unhidden. - if (g_zone.bits[i] && ((*h & 0x41) != g_zone.after[i] || g_zone.event[i])) g_zone.bits[i] = 0; - const uint16_t zone = uint16_t(*h & (thps::dd::unhide_everything() ? 0x41 : g_zone.bits[i])); // only what the zone culling hid - if (zone) { - saved.push_back(fl); - saved.push_back(*h); - *h = uint16_t(*h & ~zone); - } - } - static uint32_t frames = 0; - if (unhide && thps::verbose() && (frames++ % 300) == 0) { // evidence: hidden items, and how many of them only the zone culling hid - uint32_t named = 0; - for (uint8_t e : g_zone.event) named += e; - std::printf("[thps] draw distance: items %u, hidden %u, of them by the zone culling (unhidden for this draw) %u; items named by events %u\n", n, hidden, - uint32_t(saved.size() / 2), named); - // the ones kept hidden (not the zone culling's): index, flags, position (item +36/+40/+44, floats) - for (uint32_t i = 0, shown = 0; i < n && i < g_zone.bits.size() && shown < 12; i++) { - const uint32_t it = word(rdram, arr + 4 * i); - const uint16_t f = *flag_ptr(rdram, arr, i); - if (!(f & 0x41) || (f & g_zone.bits[i]) == (f & 0x41)) continue; - float pos[3]; - for (int k = 0; k < 3; k++) { uint32_t w = word(rdram, it + 36 + 4 * k); std::memcpy(&pos[k], &w, 4); } - std::printf("[thps] draw distance: kept hidden: item %u flags 0x%04X zone 0x%02X at (%.0f, %.0f, %.0f)\n", i, f, g_zone.bits[i], pos[0], pos[1], pos[2]); - shown++; - } - std::fflush(stdout); - } - const uint32_t renderer = word(rdram, sy.gRenderer); - const uint64_t a0 = ctx->r4, a1 = ctx->r5, a2 = ctx->r6; - const bool batched = renderer && word(rdram, renderer + kBatchOn) == 1; - // Two players: the first view's call (a2 = 0) is followed by the second's in the same frame (seen within the last few calls). - static uint32_t calls = 0, last_second = 0x80000000u; - calls++; - if (a2) last_second = calls; - const bool view_follows = !a2 && calls - last_second < 4; - // Which items this call draws, and which of them only the setting adds (beyond the game's far, or unhidden here). - const float cam[3] = {fword(rdram, view + 0x90), fword(rdram, view + 0x94), fword(rdram, view + 0x98)}; - // Extra items beyond the factor in use are held back (a back-off, a lower setting, which the world view's far does not follow until the - // camera code sets it again): those beyond the game's far past game far x factor, the unhidden ones, while the factor is below its end, - // past (factor - 1) x the game's far. - const float reach = unhide ? thps::dd::unhide_reach() * game_far : 0.0f, hold_far = game_far * thps::dd::factor(); - static std::vector> extra; // (distance to the bounding sphere, item index) - static std::vector held; // extra items beyond the factor in use (left out of every run) - static std::vector skip_addr; // each item's +0x44 (RDRAM word address) - static std::vector skip_was; - extra.clear(), held.clear(); - skip_addr.resize(n), skip_was.resize(n); - for (uint32_t i = 0; i < n; i++) { - const uint32_t it = word(rdram, arr + 4 * i); - skip_addr[i] = (it + 0x44) & 0x3FFFFFC; - skip_was[i] = word(rdram, it + 0x44); - const uint32_t fl = word(rdram, it + 4) & 0x3FFFFFE; - if (*reinterpret_cast(rdram + (fl ^ 2)) & mask) continue; // not drawn by this call at all - const float d[3] = {fword(rdram, it + 0x34) - cam[0], fword(rdram, it + 0x38) - cam[1], fword(rdram, it + 0x3C) - cam[2]}; - const float r = fword(rdram, it + 0x40), d2 = d[0] * d[0] + d[1] * d[1] + d[2] * d[2]; - if ((r + view_far) * (r + view_far) < d2) continue; // the game's far test with the far in use - bool in = true; - for (int k = 0; k < 5 && in; k++) { - const uint32_t pl = view + 0x1D0 + 12 * k; - in = fword(rdram, pl) * d[0] + fword(rdram, pl + 4) * d[1] + fword(rdram, pl + 8) * d[2] + r >= 0.0f; - } - if (!in) continue; - const bool game_draws = !(before[i] & mask) && (r + game_far) * (r + game_far) >= d2; - if (game_draws) continue; - const float gap = std::sqrt(d2) - r; - if ((before[i] & mask) ? gap > reach : gap > hold_far) held.push_back(i); - else extra.emplace_back(gap, i); - } - constexpr uint32_t kNeg = 0xBF800000u; // -1.0f - inside = true; - if (!batched) { - // Not inside a pass (THPS2's other caller, 0x8006F864, draws its own list this way; for the course items it has not been seen, - // `dd dlprobe` direct): the batcher draws at once, no lists to fill. One call, the held items left out. - g_direct_calls.fetch_add(1, std::memory_order_relaxed); - if (probe_on()) probe_view(rdram, uint32_t(a2), probe_entry, probe_entry); - // The budget per item (kItemBound): the farthest extra items that do not fit are left out with the held ones. - const size_t keep = affordable(rdram, dl_used(rdram), extra.size(), false, view_follows); - if (keep < extra.size()) { - std::sort(extra.begin(), extra.end(), [](const auto& x, const auto& y) { return x.first > y.first; }); - const size_t cut = extra.size() - keep; - for (size_t k = 0; k < cut; k++) held.push_back(extra[k].second); - thps::dd::note_cut(uint32_t(cut), dl_used(rdram), word(rdram, sy.gDlBufSize)); - } - for (uint32_t i : held) *reinterpret_cast(rdram + skip_addr[i]) = kNeg; - timer.guest([&] { thps::call_guest(sy.World_DrawItems, rdram, ctx); }); - for (uint32_t i : held) *reinterpret_cast(rdram + skip_addr[i]) = skip_was[i]; - } else { - std::sort(extra.begin(), extra.end(), [](const auto& x, const auto& y) { return x.first > y.first; }); - static std::vector in_run; - in_run.assign(n, 0); - auto run = [&](size_t from, size_t to, bool last) { - // last: everything except the extra and held items (= what the game draws); otherwise extra[from, to) - std::fill(in_run.begin(), in_run.end(), uint8_t(last ? 1 : 0)); - for (size_t k = last ? 0 : from; k < (last ? extra.size() : to); k++) in_run[extra[k].second] = uint8_t(last ? 0 : 1); - if (last) - for (uint32_t i : held) in_run[i] = 0; - for (uint32_t i = 0; i < n; i++) - if (!in_run[i]) *reinterpret_cast(rdram + skip_addr[i]) = kNeg; - ctx->r4 = a0, ctx->r5 = a1, ctx->r6 = a2; - timer.guest([&] { thps::call_guest(sy.World_DrawItems, rdram, ctx); }); - for (uint32_t i = 0; i < n; i++) *reinterpret_cast(rdram + skip_addr[i]) = skip_was[i]; - if (!last) { // this run's pass ends (its lists drawn and emptied) and the next one begins - ctx->r4 = int32_t(renderer); // sign-extended, as the recompiled code keeps addresses - timer.guest([&] { thps::call_guest(sy.Mesh_PassEnd, rdram, ctx); }); - ctx->r4 = int32_t(renderer); // sign-extended, as the recompiled code keeps addresses - timer.guest([&] { thps::call_guest(sy.Mesh_PassBegin, rdram, ctx); }); - } - }; - // The extra passes, far to near, within the display-list budget: before each pass, the items the list can no longer take are - // dropped from the far end of what is left; the nearest pass keeps its bound reserved throughout (affordable), so it is the one - // kept. Items cut from the nearest pass once passes were drawn (late) mean a pass took more than its bound. - size_t cut = 0, late = 0, passes = 0; - uint32_t cut_used = 0, late_used = 0; - for (size_t k = 0; k < extra.size();) { - const uint32_t used = dl_used(rdram); - const size_t left = extra.size() - k, keep = affordable(rdram, used, left, true, view_follows); - if (keep < left) { - const size_t near_cut = passes && std::min(left, size_t(kChunk)) > keep ? std::min(left, size_t(kChunk)) - keep : 0; - if (near_cut && !late) late_used = used; - if (left - keep > near_cut && !cut) cut_used = used; - late += near_cut, cut += left - keep - near_cut, k += left - keep; - if (k >= extra.size()) break; - } - const uint32_t u0 = probe_on() ? dl_used(rdram) : 0; - const size_t to = std::min(extra.size(), k + kChunk); - run(k, to, false); - passes++; - const uint32_t items = uint32_t(to - k); - if (items == kChunk) g_pass_avg += (float(dl_used(rdram) - used) - g_pass_avg) * 0.125f; // full passes only - k = to; - if (probe_on()) { - const uint32_t d = dl_used(rdram) - u0; - std::lock_guard lock{g_probe_mutex}; - g_probe.pass.add(d), g_probe.per_item.add(d / items); - } - } - if (cut) thps::dd::note_cut(uint32_t(cut), cut_used, word(rdram, sy.gDlBufSize)); - if (late) thps::dd::note_cut(uint32_t(late), late_used, word(rdram, sy.gDlBufSize), true); - const uint32_t probe_after = probe_on() ? dl_used(rdram) : 0; - if (probe_on()) probe_view(rdram, uint32_t(a2), probe_entry, probe_after); - run(0, 0, true); - if (probe_on()) { - const uint32_t d = dl_used(rdram) - probe_after; - std::lock_guard lock{g_probe_mutex}; - g_probe.own.add(d); - } - max_into(g_runs_hw, uint32_t((extra.size() + kChunk - 1) / kChunk + 1)); - } - ctx->r4 = a0, ctx->r5 = a1, ctx->r6 = a2; - inside = false; - for (size_t i = 0; i + 1 < saved.size(); i += 2) *reinterpret_cast(rdram + (saved[i] ^ 2)) = uint16_t(saved[i + 1]); - return 1; -} - -// The matrix pools. The gfx set-up gets both pools (420 x 64 bytes each) with one Mem_Alloc(heap, 0xD200, 8) and stores the cap (420) in -// that call's delay slot; right after the call it reads the cap back to place the second pool (pool 2 = pool 1 + cap x 64), and the -// per-frame allocator caps at it (the only other reader of the cap word). 420 is what limits `full` in the busiest courses. -// With dd_buffers = bigger (the default; src/draw_distance.cpp) this asks for kBigCap matrices per pool at every course start, whatever the -// setting, and raises the cap word before the call so the set-up places pool 2 at pool 1 + kBigCap x 64. The cap word is then set to -// what is in force: kBigCap while the setting is not `original`, else the game's 420 (sync_cap: first at the set-up's next request, the -// vertex pools, which comes after the cap was read back; then every frame from dd_game_far). Both caps index the same two pools, so the -// setting takes effect at once, in either direction; past the cap the allocator hands out the pool's last slots again, as the game does. -// With dd_buffers = original the game's own request and cap (the governor then keeps the far plane within 420). If the heap has no room -// for the bigger block (checked before the request, see thps_mem_alloc), the game's own request is made. -// The heap has room (2026-10-22, every course, one and two players): at least 2.9 MB free and a largest free block of 894,880 bytes. -// -// The display lists. The same set-up first allocates the two top-level display lists: gDlBufSize = gDlUnits x 8 (0x2400 x 8 = 72 KiB; the -// setter 0x800B8710 picks 0x3000, 96 KiB, in one mode), then two Mem_Alloc(heap, gDlBufSize, 8), before the matrix pools. Nothing caps the -// writes to a list (the frame end notices an overrun only afterwards and puts the end commands back inside the buffer), and School II at -// Full went past 72 KiB (78,376 bytes, 2026-10-22). With dd_buffers = bigger each list gets at least kBigDl bytes, whatever the setting -// (as the matrix pools: so the setting takes effect at once); a larger size of the game's own is kept. thps_gfx_setup_course marks the -// set-up, so only its two requests are changed (the front end's set-up, 0x800B9438, keeps its own 72 KiB lists). gDlBufSize is set to the -// size really allocated before each request, so both buffers always hold what it says: if the heap has no room for a bigger list (checked -// before the request: a failed request hangs the game, see thps_mem_alloc), gDlBufSize stays the game's size for that request and the next. -namespace { -constexpr uint32_t kBigDl = 0x24000; // 144 KiB per display list (2 x 72 KiB more heap in all; the heap has room: 2026-10-23 dd-heap runs) -// Tests / measurements: THPS_DD_DL_BYTES=N asks for N bytes per list instead (rounded up to 8; a larger buffer than the game's still). -uint32_t dl_min_bytes() { - static const uint32_t n = std::getenv("THPS_DD_DL_BYTES") ? (uint32_t(std::strtoul(std::getenv("THPS_DD_DL_BYTES"), nullptr, 0)) + 7u) & ~7u : kBigDl; - return n; -} -thread_local bool t_setup = false, t_dl_fallback = false; -thread_local uint32_t t_dl_calls = 0, t_dl_native = 0; -std::atomic g_dl_native{0}, g_dl_fallbacks{0}; // the game's own size of the current lists; requests that fell back to it -} // namespace - -extern "C" int thps_gfx_setup_course(uint8_t* rdram, recomp_context* ctx) { - thread_local bool inside = false; - if (inside) return 0; - inside = true, t_setup = true, t_dl_calls = 0, t_dl_native = 0, t_dl_fallback = false; - thps::dd::heap_view_begin(); // tests (THPS_DD_HEAP_FREE): the planning's view of the free list starts again - thps::dd::note_course_start(); // the governor: the setting again for this course (ceiling reset) - thps::call_guest(thps::sym().Gfx_SetupCourse, rdram, ctx); - t_setup = false, inside = false; - if (t_dl_calls) g_dl_native = t_dl_native; - // Every set-up, like the matrix pools' line (until 2026-10-23 only the first three: a player's log then looked as if later courses had - // the game's own lists) - std::printf("[thps] draw distance: display lists: 2 x %u bytes (the game's %u)%s\n", word(rdram, thps::sym().gDlBufSize), t_dl_native, - t_dl_fallback ? ", no room for the bigger ones" : ""); - std::fflush(stdout); - return 1; -} - -namespace { -constexpr uint32_t kGameCap = 420, kBigCap = 2048; -std::atomic g_heap{0}; // the heap the gfx set-up allocates from (Mem_Alloc's a0 for the matrix pools) -std::atomic g_big_block{0}; // the bigger pools of the current course (0: the game's own) -struct HeapStats { uint32_t failed, free_bytes, largest, blocks; }; -HeapStats heap_stats(uint8_t* rdram, uint32_t heap) { // free list: head at +0, next at +4, free bytes at +20; +16 = the last failed size (-1 = none) - HeapStats h{word(rdram, heap + 16), 0, 0, 0}; - for (uint32_t b = word(rdram, heap), k = 0; b && k < 100000; b = word(rdram, b + 4), k++) { - const uint32_t f = word(rdram, b + 20); - h.free_bytes += f, h.largest = std::max(h.largest, f), h.blocks++; - } - return h; -} -void put_word(uint8_t* rdram, uint32_t vaddr, uint32_t v) { *reinterpret_cast(rdram + (vaddr & 0x3FFFFFC)) = v; } -// The bigger pools are the ones in use (the set-up placed both in the block this hook got), so the cap word may be set. -bool big_in_use(uint8_t* rdram) { - const uint32_t b = g_big_block.load(); - const auto& s = thps::sym(); - return b && word(rdram, s.gMtxPools) == b && word(rdram, s.gMtxPools + 4) == b + kBigCap * 64; -} -void sync_cap(uint8_t* rdram) { // game thread - if (!big_in_use(rdram)) return; - const uint32_t want = thps::dd::enlarge_pools() ? kBigCap : kGameCap; - if (word(rdram, thps::sym().gMtxCap) != want) put_word(rdram, thps::sym().gMtxCap, want); -} -// Low-water marks of the gfx heap since the start or `dd heap reset` (under g_low_mutex). -struct HeapLow { - uint32_t calls = 0, failed = 0, biggest = 0; - uint32_t free_min = UINT32_MAX, largest_min = UINT32_MAX; // after an allocation - int64_t margin_min = INT64_MAX; // largest free block before a request - its size - uint32_t margin_size = 0; // the request that had the least margin -}; -std::mutex g_low_mutex; -HeapLow g_low; -void note_heap(const HeapStats& before, const HeapStats& after, uint32_t size, bool ok) { - std::lock_guard lock{g_low_mutex}; - g_low.calls++; - if (!ok) g_low.failed++; - g_low.biggest = std::max(g_low.biggest, size); - g_low.free_min = std::min(g_low.free_min, after.free_bytes); - g_low.largest_min = std::min(g_low.largest_min, after.largest); - const int64_t m = int64_t(before.largest) - int64_t(size); - if (m < g_low.margin_min) g_low.margin_min = m, g_low.margin_size = size; -} -} // namespace - -void thps::dd::dd_game_sync_pools(uint8_t* rdram) { sync_cap(rdram); } - -namespace { -// The set-up's requests to the gfx heap, in order (Gfx_SetupCourse, US 0x800B90BC: two display lists of gDlBufSize, the matrix pools -// 0xD200, the vertex pools 0x3E80, all align 8; nothing else from that heap in between), each as the allocator counts it. -constexpr uint32_t kGameVtxBytes = 500 * 16 * 2; -uint32_t heap_need(uint32_t n) { return ((n + 0x23) & ~3u) + 8; } -struct PlanReq { uint32_t native, big; bool can; }; -// Which of the set-up's remaining requests to make bigger (review 2026-10-26, finding 1; rest[0] is the request at hand). A request that -// fails hangs the game, and so does a later one the bigger ones left no room for: so the plan places every remaining request, first fit, on -// a copy of the free list. The native plan first: if it does not fit, nothing is enlarged (the game's own set-up fails there anyway). Then -// each enlargement in order (the first `pair` requests, the two display lists, together) is kept when the plan with it still places every -// request and leaves a largest free block of thps::dd::plan_floor (the native plan's, at most 768 KiB), so the requests after the set-up -// find a block as big as the biggest one they make. -std::vector plan_enlarge(uint8_t* rdram, uint32_t heap, const std::vector& rest, size_t pair) { - const std::vector blocks = thps::dd::heap_free_blocks(rdram, heap); - const uint32_t cache = thps::dd::heap_failed_cache(rdram, heap); - std::vector big(rest.size(), false); - auto needs = [&] { - std::vector v; - for (size_t i = 0; i < rest.size(); i++) v.push_back(big[i] ? rest[i].big : rest[i].native); - return v; - }; - const int64_t native = thps::dd::first_fit_after(blocks, needs(), cache); - if (native < 0) return big; - const int64_t floor = thps::dd::plan_floor(native); - for (size_t i = 0; i < rest.size();) { - const size_t n = i < pair ? pair - i : 1; - bool can = true; - for (size_t k = i; k < i + n && k < rest.size(); k++) can = can && rest[k].can && rest[k].big != rest[k].native; - if (can) { - for (size_t k = i; k < i + n && k < rest.size(); k++) big[k] = true; - if (thps::dd::first_fit_after(blocks, needs(), cache) < floor) - for (size_t k = i; k < i + n && k < rest.size(); k++) big[k] = false; - } - i += n; - } - return big; -} -} // namespace - -extern "C" int thps_mem_alloc(uint8_t* rdram, recomp_context* ctx) { - const auto& s = thps::sym(); - static thread_local bool inside = false, pending = false; - if (inside) return 0; - const uint32_t heap = uint32_t(ctx->r4), size = uint32_t(ctx->r5), align = uint32_t(ctx->r6); - // The set-up's next request after the pools (the vertex pools): pool 2 is placed by now, so the cap in force can be set. - if (pending) pending = false, sync_cap(rdram); - // The recompiled code does not keep a return address in ra, so the call is told apart by its arguments and by the caller's state at - // entry: size 0xD200, align 8, v0 = 420 (the cap it has just stored, in the delay slot) and the cap word = 420. No other call matches. - const bool pools = size == kGameCap * 64 * 2 && align == 8 && uint32_t(ctx->r2) == kGameCap && word(rdram, s.gMtxCap) == kGameCap; - if (pools) g_heap = heap, g_big_block = 0; - // One of the set-up's two display-list requests (see thps_gfx_setup_course): size = gDlBufSize, align 8. - const bool dl = t_setup && t_dl_calls < 2 && align == 8 && size != 0 && size == word(rdram, s.gDlBufSize); - uint32_t dl_want = size; - // A bigger request is made only when it and every request after it will succeed: the game's allocator does not survive a failure - // (Heap_AllocUnlocked's failure path calls 0x800B4818, which takes the heap's lock again while Mem_Alloc holds it: the game hangs; found - // 2026-10-23 by forcing one). So the set-up's remaining requests are planned on the free list first (plan_enlarge: first fit; size + - // 0x23 rounded to 4, plus the alignment; a size at or above the failed-size cache fails at once), and the game's own size is asked for - // when the bigger one does not fit the plan. (Until the review of 2026-10-26 only the request at hand was checked: an enlarged first - // list could leave no room for the second one's native size.) - const bool pools_can = thps::dd::big_buffers() && !std::getenv("THPS_DD_NOPOOL"); - const PlanReq mtx_req{heap_need(kGameCap * 64 * 2), heap_need(kBigCap * 64 * 2), pools_can}, vtx_req{heap_need(kGameVtxBytes), heap_need(kGameVtxBytes), false}; - if (dl) { - if (t_dl_calls++ == 0) t_dl_native = size; - if (thps::dd::big_buffers() && !t_dl_fallback) dl_want = std::max(t_dl_native, dl_min_bytes()); - // Tests: THPS_DD_DL_FAIL=1|2: that request of the set-up finds no room for the bigger list (the fallback runs). - static const uint32_t fail_at = std::getenv("THPS_DD_DL_FAIL") ? uint32_t(std::atoi(std::getenv("THPS_DD_DL_FAIL"))) : 0; - const uint32_t g = canary_on() ? kGuard : 0; - const PlanReq dl_req{heap_need(t_dl_native + g), heap_need(dl_want + g), true}; - const bool planned = dl_want != t_dl_native && fail_at != t_dl_calls && - (t_dl_calls == 1 ? plan_enlarge(rdram, heap, {dl_req, dl_req, mtx_req, vtx_req}, 2) : plan_enlarge(rdram, heap, {dl_req, mtx_req, vtx_req}, 0))[0]; - if (dl_want != t_dl_native && !planned) { - // no room for the bigger list: the game's own size for this one and the next - t_dl_fallback = true; - g_dl_fallbacks.fetch_add(1); - std::printf("[thps] draw distance: WARNING no room for a %u-byte display list (request %u of the set-up): the game's %u\n", dl_want, t_dl_calls, - t_dl_native); - std::fflush(stdout); - dl_want = t_dl_native; - } - } - const bool enlarge = pools && pools_can && plan_enlarge(rdram, heap, {mtx_req, vtx_req}, 0)[0]; - const bool measure = heap == g_heap.load(); - const HeapStats before = measure ? heap_stats(rdram, heap) : HeapStats{}; - if (enlarge) { - ctx->r5 = int32_t(kBigCap * 64 * 2); - put_word(rdram, s.gMtxCap, kBigCap); - } - const uint32_t guard = dl && canary_on() ? kGuard : 0; // THPS_DD_CANARY (debug): room for the guard after the list - if (dl) { // gDlBufSize = what this request gets (the game reads it again for the second request and every frame) - put_word(rdram, s.gDlBufSize, dl_want); - ctx->r5 = int32_t(dl_want + guard); - } - // Tests (THPS_DD_HEAP_FREE): the set-up's request, as made, from the planning's view of the free list (a WARNING if it could not fit). - const bool vpools = t_setup && size == kGameVtxBytes && align == 8; - if (t_setup && (dl || pools || vpools)) thps::dd::heap_view_take(heap_need(uint32_t(ctx->r5)), dl ? "a display list" : pools ? "the matrix pools" : "the vertex pools"); - inside = true; - thps::call_guest(s.Mem_Alloc, rdram, ctx); - if (dl) { - const uint32_t b = uint32_t(ctx->r2); - if (guard && b) fill_guard(rdram, b, word(rdram, s.gDlBufSize)); - g_dl_guarded[(t_dl_calls - 1) & 1] = guard ? b : 0; - g_dl_guard_at[(t_dl_calls - 1) & 1] = word(rdram, s.gDlBufSize); - } - const bool big = enlarge && uint32_t(ctx->r2) != 0; - inside = false; - const uint32_t got = uint32_t(ctx->r2); - if (measure) note_heap(before, heap_stats(rdram, heap), big ? kBigCap * 64 * 2 : dl ? word(rdram, s.gDlBufSize) : size, got != 0); - if (big) g_big_block = got, pending = true; - if (enlarge) { - std::printf("[thps] draw distance: matrix pools: %s (%u bytes) at 0x%08X, heap largest free block before %u\n", - big ? "2048 per frame" : "the game's 420", big ? kBigCap * 64 * 2 : size, got, before.largest); - std::fflush(stdout); - } else if (pools && thps::dd::big_buffers()) { - std::printf("[thps] draw distance: WARNING no room for the 2048-matrix pools: the game's 420 (%u bytes) at 0x%08X\n", size, got); - std::fflush(stdout); - } - if (got == 0 && measure) { // a failed allocation in the gfx heap: say so (the game may carry on without it) - static std::atomic warned{0}; - if (warned++ < 20) { - const HeapStats h = heap_stats(rdram, heap); - std::printf("[thps] draw distance: WARNING Mem_Alloc(0x%08X, %u, %u) failed: free %u bytes in %u blocks, largest %u (pools %s)\n", heap, size, - align, h.free_bytes, h.blocks, h.largest, big_in_use(rdram) ? "2048" : "the game's"); - std::fflush(stdout); - } - } - return 1; -} - -// `dd heap [reset]` (tests): the gfx heap's failed-size cache, free bytes, free blocks and the largest one, the pools' size and the cap in -// force, then the low-water marks since the start (or the last reset): least free bytes and smallest largest block after an allocation, -// the least margin between a request and the largest free block (and that request's size), failed requests, the biggest request. -std::string thps::dd::dd_game_heap(uint8_t* rdram, bool reset) { - const uint32_t heap = g_heap.load(); - if (!heap) return "none"; - const HeapStats h = heap_stats(rdram, heap); - HeapLow l; - { - std::lock_guard lock{g_low_mutex}; - l = g_low; - if (reset) g_low = HeapLow{}; - } - char buf[480]; - std::snprintf(buf, sizeof buf, "at 0x%08X failed %d free %u blocks %u largest %u mtx_pool %u mtx_cap %u dl %u dl_game %u dl_fallbacks %u low: calls %u free_min %u largest_min %u margin_min %lld " - "(size %u) failed_calls %u biggest %u view_fail %u", heap, int32_t(h.failed), h.free_bytes, h.blocks, h.largest, big_in_use(rdram) ? kBigCap : word(rdram, sym().gMtxCap), - word(rdram, sym().gMtxCap), word(rdram, sym().gDlBufSize), g_dl_native.load(), g_dl_fallbacks.load(), l.calls, l.calls ? l.free_min : 0, l.calls ? l.largest_min : 0, - l.calls ? (long long)l.margin_min : 0LL, l.margin_size, l.failed, l.biggest, thps::dd::heap_view_fails()); - return buf; -} - -// `dd item N` (tests): the item's hidden bits now, the zone record (after the draw call's stale-record check), event ownership, and -// whether "full" draws it this frame (the same rule as thps_world_draw_items). -std::string thps::dd::dd_game_item(uint8_t* rdram, uint32_t index) { - const uint32_t list = thps::sym().gWorldItems; - const uint32_t n = word(rdram, list + 16), arr = word(rdram, list + 12); - if (n == 0 || n > 8192 || index >= n) return "none"; - zone_sync(rdram, list); - const uint16_t f = *flag_ptr(rdram, arr, index); - const uint8_t zone = index < g_zone.bits.size() ? g_zone.bits[index] : 0, event = index < g_zone.event.size() ? g_zone.event[index] : 0; - const uint16_t unhidden = thps::dd::unhide_everything() ? uint16_t(f & 0x41) : 0; - uint32_t named = 0; - for (uint8_t e : g_zone.event) named += e; - char buf[160]; - std::snprintf(buf, sizeof buf, "flags 0x%04X zone 0x%02X event %u drawn %d named %u gen %llu", f, zone, event, ((f & 0x41) & ~unhidden) == 0, named, - (unsigned long long)g_zone.gen); - return buf; -} - -// `dd own N` (tests): item N is event-owned from now on, as World_FindItem would make it (thps_world_find_item). -std::string thps::dd::dd_game_own(uint8_t* rdram, uint32_t index) { - const uint32_t list = thps::sym().gWorldItems; - const uint32_t n = word(rdram, list + 16); - if (n == 0 || n > 8192 || index >= n) return "none"; - zone_sync(rdram, list); - if (index >= g_zone.event.size()) return "none"; - g_zone.event[index] = 1; - return dd_game_item(rdram, index); +// THPS2's side of the extended draw distance. The engine is shared with THPS3 and lives in the Host (src/draw_distance_thps23.cpp, with +// the notes); this file holds what is THPS2's own: the profile (offsets and sizes of the renderer's lists, no THPS3 extras) and the zone +// ownership profile. +#include "draw_distance_thps23.h" +#include "zone_ownership.h" + +// The renderer's mesh queue: 512 entries (+0x1CC, count +0x21CC) and the depth-sorted list of translucent meshes: 96 entries (+0x21D8, +// count +0x2AD8); the batch flag is +0x2AE0. The vertex pools stay the game's own, and there is no list of put-aside items. +const thps::dd23::Profile& thps::dd23::profile() { + static const Profile p{0x21CC, 512, 0x2AD8, 96, 0x2AE0, false, nullptr, nullptr}; + return p; +} + +// Zone ownership (src/zone_ownership.h): the box command shows on 1. +const thps::zones::Profile& thps::zones::profile() { + static const Profile p{false, 0x40, 0x34}; + return p; } diff --git a/decomp/thps2/recomp-host/recipes/eu/thps2.host.syms.toml b/decomp/thps2/recomp-host/recipes/eu/thps2.host.syms.toml index 5d9b24d..88e98cd 100644 --- a/decomp/thps2/recomp-host/recipes/eu/thps2.host.syms.toml +++ b/decomp/thps2/recomp-host/recipes/eu/thps2.host.syms.toml @@ -2315,7 +2315,7 @@ functions = [ { name = "func_800A1824", vram = 0x800A1824, size = 0x1AC }, { name = "func_800A19D0", vram = 0x800A19D0, size = 0x37C }, { name = "SendSuspendOrActivate_maybe", vram = 0x800A1D4C, size = 0x154 }, - { name = "func_800A1EA0", vram = 0x800A1EA0, size = 0x340 }, + { name = "Trig_SkipCommand", vram = 0x800A1EA0, size = 0x340 }, { name = "func_800A21E0", vram = 0x800A21E0, size = 0x1388 }, { name = "func_800A3568", vram = 0x800A3568, size = 0x1C0 }, { name = "func_800A3728", vram = 0x800A3728, size = 0x108 }, diff --git a/decomp/thps2/recomp-host/recipes/us/thps2.host.syms.toml b/decomp/thps2/recomp-host/recipes/us/thps2.host.syms.toml index bc607ff..f80ac98 100644 --- a/decomp/thps2/recomp-host/recipes/us/thps2.host.syms.toml +++ b/decomp/thps2/recomp-host/recipes/us/thps2.host.syms.toml @@ -2315,7 +2315,7 @@ functions = [ { name = "func_800A17F4", vram = 0x800A17F4, size = 0x1AC }, { name = "func_800A19A0", vram = 0x800A19A0, size = 0x37C }, { name = "SendSuspendOrActivate_maybe", vram = 0x800A1D1C, size = 0x154 }, - { name = "func_800A1E70", vram = 0x800A1E70, size = 0x340 }, + { name = "Trig_SkipCommand", vram = 0x800A1E70, size = 0x340 }, { name = "func_800A21B0", vram = 0x800A21B0, size = 0x1388 }, { name = "func_800A3538", vram = 0x800A3538, size = 0x1C0 }, { name = "func_800A36F8", vram = 0x800A36F8, size = 0x108 }, diff --git a/decomp/thps2/recomp-host/tests/scenarios.py b/decomp/thps2/recomp-host/tests/scenarios.py index 0077f99..f3e4df4 100644 --- a/decomp/thps2/recomp-host/tests/scenarios.py +++ b/decomp/thps2/recomp-host/tests/scenarios.py @@ -641,7 +641,7 @@ *([("press", "RIGHT"), ("wait", 45)] if lv == "school" else []), ("press", "A"), ("wait", 120), ("shot", "goals_" + lv), ("go", "A", "goals" if lv == "hangar" else "level_select", "in_level", 90), - ("wait", 30), ("vpad", "attach"), + ("wait", 30), ("vpad", "attach"), ("vpress", "a"), ("ctl", "projids"), ("ctl", "hfr"), ("ctl", "vpsplit"), ("ctl", "pcap 1 build/feel/" + lv), ("ctl", "vpad axis lefty -32000"), *(_FEEL_TRICKS if __import__("os").environ.get("FEEL_TRICKS") else [ @@ -683,7 +683,7 @@ dict(name="cheats-explore", config="cheats-explore-config", fresh_files=["cheats.cfg"], fresh_pak=True, wav=False, optin=True, args=["--skip-intro"] + _os.environ.get("CS_ARGS", "").split(), scenarios=[ ("cheats_explore", "exploration: first level, forward + ollie + flip + grind presses; skater state / rail balance / time scale samples", [ *BOOT_TO_MENU, *CAREER_TO_GOALS, ("go", "A", "goals", "in_level", 90), ("wait", 30), - ("vpad", "attach"), + ("vpad", "attach"), ("vpress", "a"), ("skater_watch", 80, _CS_TRY, ["Skater_PhysState", "Skater_RailBalance", "@gTimeScale"]), ("ctl", "vpad axis lefty 0"), ("go", "START", "in_level", "pause"), ("wait", 20), ("shot", "pause"), @@ -705,7 +705,7 @@ # Draw distance (2026-10-17): career (unlocked levels), one level with the setting under test. # Same steps as THPS1's dd-probe: a paused spot compared per factor (DD_AB), then a ride with frame times. Exploration only (optin). -# DD_RIGHTS: RIGHT presses at the level select; DD_TAG / DD_OUT: output; DD_ARGS: extra host args; DD_RIDE "lefty:frames ..." (x=... for leftx). +# DD_RIGHTS: RIGHT presses at the level select; DD_TAG / DD_OUT: output; DD_ARGS: extra host args; DD_RIDE "lefty:frames ..." (raw SDL axes, -32768..32767) (x=... for leftx). _DD_DIR = _os.path.join(_os.environ.get("DD_OUT", "/tmp/dd"), _os.environ.get("DD_TAG", "run")) _DD_SHOT = lambda name: [("ctl", f"pcap 1 {_DD_DIR}/{name}"), ("wait", 3), ("ctl", "pcap 0"), ("wait", 10)] # DD_AB "K[:S] ...": factor K, with `dd showall S` first when given (1:0 = the game's own picture inside `full`); DD_TRACE=1: `dltrace 1` after @@ -736,7 +736,7 @@ # DD_PRE "KEY:frames ...": presses before the ride, with a shot after each (e.g. with --skip-to-restart in DD_ARGS: the pause menu's # SKIP TO RESTART to one of the course's restart points: repeatable spots all over the course) *[s for st in _os.environ.get("DD_PRE", "").split() for s in (("press", st.split(":")[0]), ("wait", int(st.split(":")[1])), ("shot", "pre"))], - ("vpad", "attach"), + ("vpad", "attach"), ("vpress", "a"), ("wait", 20), # claim port 1 before any stick input *[s for st in _os.environ.get("DD_RIDE", "").split() for s in ( (("ctl", f"vpad axis leftx {st.split(':')[0][2:]}"),) if st.startswith("x=") else (("ctl", f"vpad axis lefty {st.split(':')[0]}"),)) + (("wait", int(st.split(':')[1])),)], @@ -805,7 +805,8 @@ # Heap headroom with the always-allocated 2048-matrix pools (2026-10-22, the private development notes; optin, measurement): one career # course (DH_COURSE: list position), loaded, skated ~12 s and ended from the pause menu (END RUN), `dd heap` (low-water marks since the # start) after the load, after the ride and after the end. DD_ARGS: extra host args (e.g. "--draw-distance full", "--dd-buffers original"). -_DH_PLAY = [("ctl", "vpad axis lefty -32767"), +_DH_PLAY = [("vpress", "a"), # claim port 1 before the stick-only route + ("ctl", "vpad axis lefty -32767"), *[x for k in range(12) for x in (("ctl", f"vpad axis leftx {(-32767, 0, 32767, 0)[k % 4]}"), ("ctl", "vpad button a 1"), ("wait", 8), ("ctl", "vpad button a 0"), ("wait", 22))], ("ctl", "vpad axis lefty 0"), ("ctl", "vpad axis leftx 0")] @@ -831,7 +832,7 @@ # input, then pause -> END RUN and on through the end-of-run screens (DD_LONG_NAV: presses after END RUN) to the next course. Exploration # only (optin): shots at every step, `dd` stats after each course (buffer peaks, governor), `stats` at the end. _DDL_COURSES = [int(x) for x in _os.environ.get("DD_LONG_COURSES", "0 1 2").split()] # increasing list positions -_DDL_PLAY = lambda s: [("ctl", "vpad axis lefty -32767"), *[x for k in range(max(1, s // 2)) for x in ( +_DDL_PLAY = lambda s: [("vpress", "a"), ("ctl", "vpad axis lefty -32767"), *[x for k in range(max(1, s // 2)) for x in ( ("ctl", f"vpad axis leftx {(-32767, 0, 32767, 0)[k % 4]}"), ("ctl", "vpad button a 1"), ("wait", 8), ("ctl", "vpad button a 0"), ("wait", 52))], ("ctl", "vpad axis lefty 0"), ("ctl", "vpad axis leftx 0")] _DDL_NAV = [s for p in _os.environ.get("DD_LONG_NAV", "A:120 DOWN:20 DOWN:20 A:150").split() for s in (("press", p.split(":")[0]), ("wait", int(p.split(":")[1])), ("shot", "nav"))] @@ -965,7 +966,7 @@ ("ctl_match", "dd", r"mode=original .* mtx_cap=420 "), ("ui", "esc"), ("ui_state", "open=1"), ("ui", "tab 2"), ("ui", "click #lbl_dd_full"), ("log", r"draw distance: full"), ("ui", "esc"), ("ctl", "dd reset"), - ("vpad", "attach"), + ("vpad", "attach"), ("vpress", "a"), ("ctl", "vpad axis lefty -32767"), ("wait", 150), *[s for k in range(6) for s in (("ctl", f"vpad axis leftx {32767 if k % 2 else -32767}"), ("wait", 20), ("ctl", "vpad axis leftx 0"), ("wait", 20))], ("ctl", "vpad axis lefty 0"), ("wait", 30), @@ -986,7 +987,8 @@ # guards are on (`dd dlprobe` canary 1; until 2026-10-26 it only asked for the variable in this comment, and `canary_bad 0` passed with no # guard at all). Expected: no guard word changed, no budget cut at the default budget, no panic, no failed heap request. The forced cuts # are dd-cut's. -_DS_RIDE = [("ctl", "vpad axis lefty -32767"), +_DS_RIDE = [("vpress", "a"), # claim port 1 before the stick-only route + ("ctl", "vpad axis lefty -32767"), *[x for k in range(6) for x in (("ctl", f"vpad axis leftx {(-32767, 32767)[k % 2]}"), ("wait", 15), ("ctl", "vpad axis leftx 0"), ("wait", 10))], ("ctl", "dd mode original"), ("wait", 4), ("ctl", "dd mode full"), ("wait", 4), ("ctl", "dd mode 2x"), ("wait", 4), ("ctl", "dd mode full"), *[x for k in range(4) for x in (("ctl", f"vpad axis leftx {(32767, -32767)[k % 2]}"), ("wait", 20))], @@ -1018,7 +1020,8 @@ # Frame pacing in a visible window (2026-10-23, the user's "stuttery" report; optin): the title left alone until the attract demo runs # (frame times over PACE_DEMO frames), or with PACE_MODE=ride the first course (frame times over a 12 s ride). PACE_ARGS: host args (e.g. # "--refresh 60"; another rate, test-only: THPS_TEST_REFRESH=display|N in the environment). Run it with THPS2_BINARY of the build to compare. -_PACE_RIDE = [("ctl", "vpad axis lefty -32767"), +_PACE_RIDE = [("vpress", "a"), # claim port 1 before the stick-only route + ("ctl", "vpad axis lefty -32767"), *[x for k in range(12) for x in (("ctl", f"vpad axis leftx {(-32767, 0, 32767, 0)[k % 4]}"), ("wait", 30))], ("ctl", "vpad axis lefty 0"), ("ctl", "vpad axis leftx 0")] SESSIONS += [ @@ -1037,7 +1040,7 @@ # Fog (src/fog.cpp, graphics setting `fog`; optin, evidence): one course's run start, paused (FS_PAUSE=0: not paused), captured with the # game's fog, Soft and Off, then the game's fog again (a second capture of the same state shows what moves on its own). FS_RIGHTS picks -# the course, FS_ARGS adds flags (e.g. `--draw-distance full`), FS_RIDE "lefty:frames ..." (x=leftx:frames turns) rides to another spot +# the course, FS_ARGS adds flags (e.g. `--draw-distance full`), FS_RIDE "lefty:frames ..." (raw SDL axes, -32768..32767) (x=leftx:frames turns) rides to another spot # first, FS_STEPS "ctl command;shot NAME;..." adds steps (`fog soft 0.5;shot s50`: Soft starting at half the far plane). Captures: # /fs_/ (composited frames at --present-size), the `fog` counters in the notes. _FS_SHOT = lambda name: [("pcap", 3, f"fs_{name}")] @@ -1053,7 +1056,7 @@ _FS_ARGS = ["--skip-intro", "--hidden", "--unlock-levels", "--present-size", "960x540", "--aspect", "16:9", "--window", "1280x720", "--refresh", "60"] + _os.environ.get("FS_ARGS", "").split() _FS_PAUSE = [ - *([("vpad", "attach"), + *([("vpad", "attach"), ("vpress", "a"), ("wait", 20), # claim port 1 before any stick input *[s for st in _os.environ["FS_RIDE"].split() for s in ( ("ctl", f"vpad axis leftx {st[2:].split(':')[0]}") if st.startswith("x=") else ("ctl", f"vpad axis lefty {st.split(':')[0]}"), ("wait", int(st.split(":")[1])))], @@ -1101,6 +1104,22 @@ def _fs_to_run(rights, settle=2): from menu_session import fresh_defaults_session # noqa: E402 SESSIONS.append(fresh_defaults_session(_fs_to_run(1))) +# Culling boxes and state boxes (v0.3, game/draw_distance.cpp: the trigger table). One course's run start at Full (DZ_RIGHTS: RIGHT +# presses at the level select, 0 = The Hangar): the host prints the course's table once ("zones: ... culling boxes N (items M), state +# boxes ..."), to compare with the research table; exploration only (optin). DZ_STEPS "ctl command;..." adds steps after it. +SESSIONS += [ + dict(name="dd-zones", config="dd-zones-config", fresh_pak=True, wav=False, optin=True, + args=["--skip-intro", "--hidden", "--unlock-levels", "--draw-distance", "full", "--window", "960x540"] + _os.environ.get("DZ_ARGS", "").split(), + scenarios=[ + ("dd_zones", "the course's trigger table: culling and state boxes (the host log)", [ + *_fs_to_run(int(_os.environ.get("DZ_RIGHTS", "0")), 60), + ("log", r"draw distance: zones: trigger file of [1-9]"), + ("ctl", "dd"), + *[s for c in _os.environ.get("DZ_STEPS", "").split(";") if c.strip() for s in (("ctl", c.strip()), ("wait", 20))], + ]), + ]), +] + # A RETRY and the zone record (review 2026-10-26, finding 7): School II at Full. RETRY runs the course set-up again and rebuilds the item # list at the same address and count, so the record (which items the zone culling hid, which an event owns) must start again: an item made # event-owned in the first run (`dd own`, as an event naming it would) is not after the RETRY, and the items the set-up named are the same @@ -1136,7 +1155,8 @@ def _fs_to_run(rights, settle=2): # its bound, so the cuts leave out the farthest and none is made after the passes began (late_cuts=0: before the fix the passes after the # first were counted at their average and an expensive far pass left the nearest ones out). The guard bytes are on (THPS_DD_CANARY) and # intact. dd-stress checks the default budget (no cut at all). -_DC_RIDE = [("ctl", "vpad axis lefty -32767"), +_DC_RIDE = [("vpress", "a"), # claim port 1 before the stick-only route + ("ctl", "vpad axis lefty -32767"), *[x for k in range(8) for x in (("ctl", f"vpad axis leftx {(-32767, 32767)[k % 2]}"), ("wait", 15), ("ctl", "vpad axis leftx 0"), ("wait", 10))], ("ctl", "vpad axis lefty 0"), ("wait", 20)] SESSIONS += [ @@ -1178,3 +1198,7 @@ def _fs_to_run(rights, settle=2): ]), ]), ] + +# Input-only canonical rides (local recordings; shared harness steps consume playback through EOF). +from ride_sessions import ride_sessions # noqa: E402 +SESSIONS += ride_sessions(_fs_to_run(0, 2), _CS_TRY, start_after="level_select", required_states={1: 10}) diff --git a/decomp/thps2/recomp-host/versions/eu/data_syms.toml b/decomp/thps2/recomp-host/versions/eu/data_syms.toml index 365e0a5..62b6975 100644 --- a/decomp/thps2/recomp-host/versions/eu/data_syms.toml +++ b/decomp/thps2/recomp-host/versions/eu/data_syms.toml @@ -23,11 +23,13 @@ gWorldItems = 0x800DD7B4 # us + 0xC0 (translate_data.py, 26 votes) gTimeScale = 0x800E886C # gPhysicsFrameTime * 7680 each frame (the frame update; Slow-Nic: / 4 while the skater does a trick) gMusNumChannels = 0x801020A0 # MUS library globals: channel count; USA + 0xC0 (MusHandleStop 0x800D403C) gMusChannels = 0x801020A8 # MUS library globals: the channel records (304 bytes each) +gTrgNodes = 0x800F3C10 # us + 0xC0 (the same loads in Trig_LoadTRG, eu 0x800A3728) +gTrgNodeCount = 0x800F3C14 [field] View_Frustum = 0x160 Skater_ControlPtr = 0x1B04 -Skater_PhysState = 0x1960 +Skater_PhysState = 0x1EF0 # physics dispatch state: 0 ground, 1 air, 4 rail (USA 0x8008D7F4; Europe 0x8008D824) Skater_RailBalance = 0x1D28 # rail lean; Cheat_IsOn(Perfect Balance) -> 0 in the rail physics Intro_Timer = 0x250 # t2_intro screen: legal-text timer (float) Intro_State = 0x260 # t2_intro screen: state word diff --git a/decomp/thps2/recomp-host/versions/eu/host_force.toml b/decomp/thps2/recomp-host/versions/eu/host_force.toml index 5478497..8e410f5 100644 --- a/decomp/thps2/recomp-host/versions/eu/host_force.toml +++ b/decomp/thps2/recomp-host/versions/eu/host_force.toml @@ -30,6 +30,7 @@ splits = [0x800B6390] # USA 0x800B6360 + 0x30: the same hidden start after the "0x800C50D0" = "Mesh_PassBegin" # = USA + 0x20 (the same body) "0x800C50F0" = "Mesh_PassEnd" # = USA + 0x20 (the same body) "0x800C6200" = "Mesh_QueueEmit" # = USA + 0x20 (the same body; the renderer offsets +0x21CC / +0x2AD8 are the same) +"0x800A1EA0" = "Trig_SkipCommand" # = USA + 0x30 (the same body; only its jumps differ) "0x80042EA4" = "World_FindItem" # (list, name checksum) -> item: how the scripts' per-item commands (show / hide / kill by name) and the gameplay code find an item "0x800B6848" = "Tv_IsHomeRegion" # returns gTvType == 0 (PAL console; USA's has the opposite sense): the region check "0x800D1EF4" = "Music_LoadSong" # = USA 0x800D1E34 + 0xC0 (same code) diff --git a/decomp/thps2/recomp-host/versions/host_syms.toml b/decomp/thps2/recomp-host/versions/host_syms.toml index aebaece..8294120 100644 --- a/decomp/thps2/recomp-host/versions/host_syms.toml +++ b/decomp/thps2/recomp-host/versions/host_syms.toml @@ -16,6 +16,7 @@ funcs = [ "Gfx_SetupCourse", # (): the course gfx set-up (each course start, RETRY): display lists, matrix and vertex pools (draw distance: bigger display lists) "Mesh_QueueEmit", # (renderer): draws the renderer's mesh queue and sorted list of one pass (draw distance: game/draw_distance.cpp) "Music_LoadSong", # (player, song): the music player copies the next song over the playing one (src/music.cpp) + "Trig_SkipCommand", # (command) -> the next command of a trigger command list, 0 at its end (draw distance: the zone classification) ] # Functions inside the front-end overlays, named by decomp/common/overlay_recomp.py from each class's @@ -47,6 +48,8 @@ addr = [ "gRenderer", # draw distance: pointer to the renderer (mesh queue: 512 entries at +0x1CC, count +0x21CC; sorted list: 96 at +0x21D8, count +0x2AD8) "gMusNumChannels", # MUS library: number of channels (word) "gMusChannels", # MUS library: pointer to the channel records (304 bytes each; src/music.cpp) + "gTrgNodes", # draw distance: pointer to the course trigger file's node table (pointers to the nodes; Trig_LoadTRG) + "gTrgNodeCount", # draw distance: the number of trigger nodes (word) ] # Struct field offsets (versions//data_syms.toml [field]): layout can differ between builds. diff --git a/decomp/thps2/recomp-host/versions/us/data_syms.toml b/decomp/thps2/recomp-host/versions/us/data_syms.toml index d2a65af..966559d 100644 --- a/decomp/thps2/recomp-host/versions/us/data_syms.toml +++ b/decomp/thps2/recomp-host/versions/us/data_syms.toml @@ -22,11 +22,13 @@ gRenderer = 0x800FAFB8 # pointer to the renderer: World_DrawItems gTimeScale = 0x800E87AC # gPhysicsFrameTime * 7680 each frame (the frame update; Slow-Nic: / 4 while the skater does a trick) gMusNumChannels = 0x80101FE0 # MUS library globals: channel count; MusHandleStop 0x800D3F7C / __MusIntMain 0x800D4A20 read both gMusChannels = 0x80101FE8 # MUS library globals: the channel records (304 bytes each) +gTrgNodes = 0x800F3B50 # the trigger file's node table: pointers to the nodes (Trig_LoadTRG 0x800A36F8 reads it) +gTrgNodeCount = 0x800F3B54 # its node count (word) [field] View_Frustum = 0x160 # l, r, b, t, near, far (floats) Skater_ControlPtr = 0x1B04 -Skater_PhysState = 0x1960 +Skater_PhysState = 0x1EF0 # physics dispatch state: 0 ground, 1 air, 4 rail (USA 0x8008D7F4; Europe 0x8008D824) Skater_RailBalance = 0x1D28 # rail lean; Cheat_IsOn(Perfect Balance) -> 0 in the rail physics Intro_Timer = 0x250 # t2_intro screen: legal-text timer (float) Intro_State = 0x260 # t2_intro screen: state word diff --git a/decomp/thps2/recomp-host/versions/us/host_force.toml b/decomp/thps2/recomp-host/versions/us/host_force.toml index 983d5d7..ad99777 100644 --- a/decomp/thps2/recomp-host/versions/us/host_force.toml +++ b/decomp/thps2/recomp-host/versions/us/host_force.toml @@ -31,6 +31,7 @@ splits = [0x800B6360] # run-time lookup miss (LOG H1): hidden start inside fun "0x800C50B0" = "Mesh_PassBegin" # (renderer): batching on (+0x2AE0 = 1); World_DrawItems' caller 0x800336E0 wraps it in a begin / end pair "0x800C50D0" = "Mesh_PassEnd" # (renderer): flushes the batch (0x800C5A64), Mesh_QueueEmit, then empties the lists and turns batching off "0x800C61E0" = "Mesh_QueueEmit" # (renderer): draws the mesh queue (512 entries, the flush 0x800C5A64 drops the rest) and the depth-sorted list (96); called from the pass end 0x800C50D0 +"0x800A1E70" = "Trig_SkipCommand" # (command) -> next command, 0 at 0xFFFF: the trigger interpreter's skip (a leaf; draw distance walks the course's command lists with it) "0x80042E74" = "World_FindItem" # (list, name checksum) -> item: how the scripts' per-item commands (show / hide / kill by name) and the gameplay code find an item "0x800B6818" = "Tv_IsHomeRegion" # returns gTvType != 0 (NTSC/MPAL console): the region check of func_80037350 "0x800D1E34" = "Music_LoadSong" # (player, song): MusHandleStop(old, 0), then the song is copied into the player's one buffer (src/music.cpp) @@ -41,3 +42,19 @@ splits = [0x800B6360] # run-time lookup miss (LOG H1): hidden start inside fun # N64Recomp config pieces (tools/gen_versions.py). The VR4300 has no COP2; these paths must be dead in practice. [cop2] patch = { Cop2_GteLoad = [0x800454A8, 0x800454AC, 0x800454B0, 0x800454B4, 0x800454B8, 0x800454BC, 0x800454D4, 0x800454D8, 0x800454DC] } + +# Opt-in configured rides: audited US writer only. Other classes stay unsupported. +[ride_step] +override = 0x800F8958 +frame_time = 0x800F5858 +frame_ticks = 0x800F585C + +[[config.hook]] +func = "func_800B97AC" +before_vram = 0x800B97F8 +text = "thps_ride_step_begin(rdram, ctx);" + +[[config.hook]] +func = "func_800B97AC" +before_vram = 0x800B9878 +text = "thps_ride_step_end(rdram, ctx);"