From d2fa70f388a67a746ab5efb3ae554422b3e089da Mon Sep 17 00:00:00 2001 From: trentw <532672+trentw@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:50:45 -0800 Subject: [PATCH 1/2] Move to THPS64Host v0.3.0 - The THPS64Host submodule and the CI and release workflows (`@v0.3.0`, `host-tag: v0.3.0`) move to v0.3.0. With it, School II's ground no longer flashes at Full, and Philadelphia's trees and buildings no longer smear or open holes with frame interpolation. - Draw distance uses the host's shared THPS2/THPS3 engine and zone ownership: this game keeps only its addresses and a small profile. At Full it draws the zones the courses hide for distance, and only those. The Hangar's crashed ceiling and floor lights, and rooms, stay as the game sets them. - Configured ride timing for the USA release: the writer hooks cover both of the game's timing outputs. Made with AI assistance. --- .github/workflows/ci.yml | 4 +- .github/workflows/release.yml | 4 +- CHANGELOG.md | 14 + decomp/common | 2 +- decomp/thps2/recomp-host/CMakeLists.txt | 2 + .../thps2/recomp-host/game/draw_distance.cpp | 854 +----------------- .../recipes/eu/thps2.host.syms.toml | 2 +- .../recipes/us/thps2.host.syms.toml | 2 +- .../recomp-host/versions/eu/data_syms.toml | 4 +- .../recomp-host/versions/eu/host_force.toml | 1 + .../thps2/recomp-host/versions/host_syms.toml | 3 + .../recomp-host/versions/us/data_syms.toml | 4 +- .../recomp-host/versions/us/host_force.toml | 17 + 13 files changed, 67 insertions(+), 846 deletions(-) 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..f5a1547 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ 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. + +### 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. + ## [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/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);" From e6d9f90fcd5984a202b6af7320a067f10a631326 Mon Sep 17 00:00:00 2001 From: trentw <532672+trentw@users.noreply.github.com> Date: Sat, 7 Nov 2026 11:50:45 -0800 Subject: [PATCH 2/2] Tests: recorded rides and the actual physics state - An opt-in session records and replays a complete ride, and test rides claim a controller before steering. - Skating tests read the game's actual physics dispatch state, so air and rail checks describe the skater correctly. - A zone-table check prints each course's culling and state boxes. Made with AI assistance. --- CHANGELOG.md | 3 ++ decomp/thps2/recomp-host/tests/scenarios.py | 48 +++++++++++++++------ 2 files changed, 39 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f5a1547..25ce675 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,8 @@ All notable changes to THPS2 64: Recompiled are listed here. The format is based 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 @@ -20,6 +22,7 @@ All notable changes to THPS2 64: Recompiled are listed here. The format is based 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 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})