mirror of
https://github.com/SpeedBreakerProject/speedbreaker.git
synced 2026-10-28 02:03:29 +01:00
Co-authored-by: byteofmydream <12628004+byteofmydream@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1498 lines
73 KiB
C++
1498 lines
73 KiB
C++
// SpeedBreaker runtime. GPL-3.0-or-later (see COPYING).
|
|
//
|
|
// HFR (v018-hfr; path A of the 120 fps study: v015/hfr-scoping, v017/hfr-spike
|
|
// and v018/hfr in the work notes). NFSMW_HFR=1: the world keeps its 60 steps
|
|
// a second and its 60 Hz guest vblank, and each step is drawn as many times as
|
|
// the display shows pictures between two guest vblanks
|
|
// (video::HfrPicturesPerStep: 2 at 120 Hz), first at fractions of the way
|
|
// from the last step to this one, then as it is. The in-between pictures'
|
|
// swaps wait for no guest vblank (as with D3DPRESENT_INTERVAL_IMMEDIATE), and
|
|
// the presenter shows each at its share of the vblank before the real one.
|
|
// The world never sees an in-between pose.
|
|
//
|
|
// The frame (sub_823AFE20) steps the world, updates the cameras (the camera
|
|
// director, sub_82168028) and the cars' render copies (sub_824E9C38 copies the
|
|
// body's matrix to the render object's +816), then renders (sub_823AFC68 ->
|
|
// sub_82441050). For an in-between picture at fraction t:
|
|
// - each car's +816 is blended t of the way from its value before this
|
|
// frame's update to its value after it; each source camera's view matrix
|
|
// (and FOV) from the one the last real picture used to its current one.
|
|
// Cameras the render derives from those (the sun-shadow views, the
|
|
// reflections, the environment views) follow by themselves;
|
|
// - what the render changes every time it runs is put back afterwards, so
|
|
// the real picture is drawn exactly as it is at 60: the per-frame render
|
|
// flags (the particle buffer's gate keyed on RealTimeFrames, sub_8243B0B0:
|
|
// else the real picture draws no particles), the eye adaptation
|
|
// (sub_82223220 moves 2% per call) and its luminance ping-pong, the
|
|
// cameras' own history (sub_82161000 keeps the last call's pose and
|
|
// derives velocity from it), the camera-update counter, the render's clock,
|
|
// and the nitrous-trail rings (RenderFlares writes one per render);
|
|
// - the game renders on two threads ([0x828FBB50] set, as in races and
|
|
// cutscenes): this one records each pass as a callback, and the XEX's
|
|
// entry thread plays them back (sub_82441B60 -> sub_823C8290) while this
|
|
// one goes on (the next frame waits in sub_82441DB0 until [0x82A2CF40],
|
|
// "a frame being played", clears; this thread sets it as it starts a
|
|
// frame, sub_82441EF0). The passes read live state when they are played,
|
|
// and the end-of-frame pass adapts the exposure and presents, so the
|
|
// in-between picture's state is put back only once it has been played:
|
|
// this thread waits for it, as the game's next frame would.
|
|
// Everything is put back before the real picture's render starts. A frame
|
|
// that runs no world step (or several: a hitch) draws no in-between picture.
|
|
#include <stdafx.h>
|
|
#include "hfr.h"
|
|
#include "hfr_blend.h"
|
|
#include "hfr_grid.h"
|
|
#include "frame_time.h"
|
|
|
|
#include <kernel/function.h>
|
|
#include <kernel/write_watch.h>
|
|
#include <user/settings.h>
|
|
#include <video/presenter.h>
|
|
#include <video/vblank_lock.h>
|
|
|
|
#include <algorithm>
|
|
#include <atomic>
|
|
#include <chrono>
|
|
#include <cmath>
|
|
#include <cstring>
|
|
#include <format>
|
|
#include <iterator>
|
|
#include <string>
|
|
#include <thread>
|
|
#include <unordered_map>
|
|
#include <unordered_set>
|
|
#include <vector>
|
|
|
|
namespace
|
|
{
|
|
// Settings > Display > Smooth Motion (or NFSMW_HFR=1), as it was at
|
|
// launch: hfr::Init, after the settings load and before the game runs.
|
|
bool s_enabled = false;
|
|
bool s_render2 = false;
|
|
// NFSMW_HFR_PICTURES=<n> (2-4): that many pictures a step whatever the
|
|
// display (tests: headless, or a display the presenter can't time).
|
|
const int s_forcedPictures = [] {
|
|
const char* v = std::getenv("NFSMW_HFR_PICTURES");
|
|
const int n = v ? std::atoi(v) : 0;
|
|
return n >= 2 && n <= 4 ? n : 0;
|
|
}();
|
|
// NFSMW_HFR_FRACTIONS="<f>,<f>|<f>|...": the fractions each step draws,
|
|
// step after step in turn, the last of a step its own picture (1: the
|
|
// real one; less: a step drawn only between, as on a display that isn't
|
|
// a whole multiple of 60 Hz: "0.667|0.333,1" is 90 Hz's three pictures
|
|
// every two steps). DIAGNOSTIC, any display.
|
|
const std::vector<std::vector<float>> s_forcedFractions = [] {
|
|
std::vector<std::vector<float>> out;
|
|
if (const char* v = std::getenv("NFSMW_HFR_FRACTIONS"))
|
|
{
|
|
std::vector<float> step;
|
|
const char* p = v;
|
|
while (*p)
|
|
{
|
|
char* end = nullptr;
|
|
const float f = std::strtof(p, &end);
|
|
if (end == p)
|
|
break;
|
|
if (f > 0.0f && f <= 1.0f && step.size() < 4)
|
|
step.push_back(f);
|
|
p = end;
|
|
if (*p == ',')
|
|
p++;
|
|
else if (*p == '|' || !*p)
|
|
{
|
|
if (!step.empty())
|
|
out.push_back(step);
|
|
step.clear();
|
|
if (*p)
|
|
p++;
|
|
}
|
|
}
|
|
if (!step.empty())
|
|
out.push_back(step);
|
|
fprintf(stderr, "[hfr] NFSMW_HFR_FRACTIONS: %zu steps in turn (DIAGNOSTIC)\n", out.size());
|
|
}
|
|
return out;
|
|
}();
|
|
uint32_t s_fractionCycle = 0;
|
|
// NFSMW_HFR_BLEND=0: the in-between picture without the blending (the per-render state still put back);
|
|
// =cars, =cameras or =particles blends only those (DIAGNOSTIC: M1's launch A/B, M2's particles).
|
|
const char* const s_blendMode = std::getenv("NFSMW_HFR_BLEND");
|
|
const bool s_blendAll = !s_blendMode || std::strcmp(s_blendMode, "1") == 0;
|
|
const bool s_blendCars = s_blendAll || (s_blendMode && std::strcmp(s_blendMode, "cars") == 0);
|
|
const bool s_blendCameras = s_blendAll || (s_blendMode && std::strcmp(s_blendMode, "cameras") == 0);
|
|
const bool s_blendParticles = s_blendAll || (s_blendMode && std::strcmp(s_blendMode, "particles") == 0);
|
|
const bool s_blend = s_blendCars || s_blendCameras || s_blendParticles;
|
|
|
|
std::atomic<uint64_t> s_staleLuminance{ 0 }; // real pictures that took the last luminance reading (M3)
|
|
const bool s_vbSlot = [] { const char* v = std::getenv("NFSMW_HFR_VBSLOT"); return v && v[0] == '1'; }();
|
|
// Set by the main loop thread around an in-between picture; read by the
|
|
// render thread too (the picture's swap: sub_82598670 here, and VdSwap).
|
|
std::atomic<bool> s_inBetween{ false };
|
|
std::atomic<float> s_fraction{ 0.0f };
|
|
// A step whose own picture is drawn between too (its fraction < 1: no
|
|
// real picture), while it is drawn; 0 otherwise.
|
|
std::atomic<float> s_ownFraction{ 0.0f };
|
|
// On a grid that isn't a multiple of 60 Hz (video::HfrGrid): the step's
|
|
// pictures' refreshes (steady ns), and the one being drawn (its swap
|
|
// carries it to the presenter); 0 elsewhere.
|
|
int64_t s_targets[4] = {};
|
|
double s_gridFrom = 0; // when the last step's state is shown (main loop thread)
|
|
std::atomic<int64_t> s_target{ 0 };
|
|
// How long after the guest vblank that releases a step its last picture
|
|
// is shown there: a step more than the vblank (pictures of a step are
|
|
// drawn after it, and the earliest falls just after the last step's) and
|
|
// the time to draw them.
|
|
constexpr int64_t kGridLeadNs = 5'000'000;
|
|
// Main loop thread only.
|
|
uint32_t s_step = 0;
|
|
uint32_t s_lastRealStep = 0; // the step the last real picture showed
|
|
uint32_t s_threadedFrames = 0; // frames since the render thread took the passes (0: not now)
|
|
bool s_lastDrewBetween = false; // the last frame drew in-between pictures
|
|
uint32_t s_steadyFrames = 0; // drawn frames of exactly one world step in a row
|
|
uint64_t s_lastFrames = 0, s_lastMultiStep = 0, s_lastNoStep = 0; // frame_time.cpp's counts at the last drawn frame
|
|
uint64_t s_frameCount = 0; // render entries that rendered (NFSMW_HFR_WRITESET's frames)
|
|
int s_writeSet = 0; // this frame's NFSMW_HFR_WRITESET phase
|
|
|
|
constexpr uint32_t kStepper = 0x82A2CB6C; // the world stepper; +40: steps run
|
|
constexpr uint32_t kRenderThreaded = 0x828FBB50; // nonzero: passes played on the render thread
|
|
constexpr uint32_t kFramePlaying = 0x82A2CF40; // 1: the render thread is playing a frame
|
|
constexpr uint32_t kRenderSkip = 0x82A2CC08; // > 0: the render entry skips (and counts down)
|
|
constexpr uint32_t kCarMatrix = 816; // vehicle render object: the body's matrix as drawn
|
|
constexpr uint32_t kCarInfo = 68; // -> its CarRenderInfo
|
|
constexpr uint32_t kTrailRing = 4512, kTrailRingBytes = 248; // RenderFlares' 3 matrices, 3 positions, index
|
|
constexpr uint32_t kCameraFov = 196; // camera object: FOV (u16)
|
|
constexpr uint32_t kCameraBytes = 680; // what sub_82161000 keeps (its dt at +676)
|
|
constexpr uint32_t kDevicePresentInterval = 13772; // D3D device: 1, or 0x80000000 (immediate)
|
|
|
|
void Load(uint8_t* base, uint32_t a, float m[16])
|
|
{
|
|
for (int i = 0; i < 16; i++)
|
|
{
|
|
const uint32_t bits = PPC_LOAD_U32(a + i * 4);
|
|
memcpy(&m[i], &bits, 4);
|
|
}
|
|
}
|
|
void Store(uint8_t* base, uint32_t a, const float m[16])
|
|
{
|
|
for (int i = 0; i < 16; i++)
|
|
{
|
|
uint32_t bits;
|
|
memcpy(&bits, &m[i], 4);
|
|
PPC_STORE_U32(a + i * 4, bits);
|
|
}
|
|
}
|
|
uint32_t Step(uint8_t* base)
|
|
{
|
|
const uint32_t stepper = PPC_LOAD_U32(kStepper);
|
|
return stepper ? PPC_LOAD_U32(stepper + 40) : 0;
|
|
}
|
|
|
|
// Guest memory put back after the in-between picture (newest last).
|
|
struct Saved
|
|
{
|
|
uint32_t address;
|
|
std::vector<uint8_t> bytes;
|
|
};
|
|
std::vector<Saved> s_saved;
|
|
std::unordered_set<uint32_t> s_savedCameras;
|
|
void Save(uint8_t* base, uint32_t address, uint32_t size)
|
|
{
|
|
s_saved.push_back({ address, std::vector<uint8_t>(base + address, base + address + size) });
|
|
}
|
|
void RestoreAll(uint8_t* base)
|
|
{
|
|
for (auto it = s_saved.rbegin(); it != s_saved.rend(); ++it)
|
|
memcpy(base + it->address, it->bytes.data(), it->bytes.size());
|
|
s_saved.clear();
|
|
s_savedCameras.clear();
|
|
}
|
|
|
|
// A step's own picture drawn between (BeginOwn/EndOwn): the poses
|
|
// blended as for an in-between picture and put back once it has been
|
|
// played, but what the render changes every time it runs kept, as the
|
|
// real picture's would be. A camera's own history (sub_82161000 copies
|
|
// its state, +0-223, to +224 at each render) is left as the real
|
|
// picture would leave it, so the next step's motion is real to real, and
|
|
// the cameras' records (the next in-between picture's start) are the
|
|
// step's real poses.
|
|
struct OwnCamera
|
|
{
|
|
uint32_t camera;
|
|
uint8_t state[224];
|
|
};
|
|
std::vector<Saved> s_ownSaved;
|
|
std::vector<OwnCamera> s_ownCameras;
|
|
|
|
// A car's render copy: before and after this frame's update.
|
|
struct Car
|
|
{
|
|
float before[16], after[16];
|
|
uint32_t step = 0;
|
|
};
|
|
std::unordered_map<uint32_t, Car> s_cars; // vehicle render object -> its poses
|
|
// A camera's view matrix and FOV as the last real picture used them.
|
|
struct Camera
|
|
{
|
|
float m[16];
|
|
uint16_t fov = 0;
|
|
uint32_t step = 0;
|
|
};
|
|
std::unordered_map<uint32_t, Camera> s_cameras;
|
|
|
|
// The emitter system's particles (smoke, dust, sparks: the emitter render
|
|
// sub_8222ADC0 draws them from here): [0x82A34940] > 0 while any exist;
|
|
// 66 lists (+268, 8 bytes each: a circular list's head) of nodes whose
|
|
// +8 is an emitter, whose particles hang off its +128 (circular, next at
|
|
// +0), each with its position at +80. The world moves them once a step;
|
|
// an in-between picture draws each t of the way from where it was at the
|
|
// last step, as the cars (a particle not seen then, or one that jumped,
|
|
// a reused slot, is drawn where it is).
|
|
constexpr uint32_t kEmitterSystem = 0x82A34940, kEmitterLists = 66, kEmitterListHeads = 268;
|
|
constexpr uint32_t kEmitterParticles = 128, kParticlePosition = 80;
|
|
constexpr float kParticleMaxMove = 5.0f;
|
|
struct Particle
|
|
{
|
|
float pos[3];
|
|
uint32_t step = 0;
|
|
};
|
|
std::unordered_map<uint32_t, Particle> s_particles; // particle -> its position at a step
|
|
struct ParticleNow
|
|
{
|
|
uint32_t address;
|
|
float now[3], before[3];
|
|
bool blend;
|
|
};
|
|
std::vector<ParticleNow> s_particlesNow; // this frame's (main loop thread)
|
|
|
|
template <typename F>
|
|
void ForEachParticle(uint8_t* base, F&& fn)
|
|
{
|
|
if (int32_t(PPC_LOAD_U32(kEmitterSystem)) <= 0)
|
|
return;
|
|
uint32_t guard = 0;
|
|
for (uint32_t i = 0; i < kEmitterLists; i++)
|
|
{
|
|
const uint32_t head = kEmitterSystem + kEmitterListHeads + i * 8;
|
|
for (uint32_t node = PPC_LOAD_U32(head); node && node != head && guard < 200000; node = PPC_LOAD_U32(node), guard++)
|
|
{
|
|
const uint32_t emitter = PPC_LOAD_U32(node + 8);
|
|
if (!emitter)
|
|
continue;
|
|
const uint32_t list = emitter + kEmitterParticles;
|
|
for (uint32_t p = PPC_LOAD_U32(list); p && p != list && guard < 200000; p = PPC_LOAD_U32(p), guard++)
|
|
fn(p);
|
|
}
|
|
}
|
|
}
|
|
void LoadVec(uint8_t* base, uint32_t a, float v[3])
|
|
{
|
|
for (int i = 0; i < 3; i++)
|
|
{
|
|
const uint32_t bits = PPC_LOAD_U32(a + i * 4);
|
|
memcpy(&v[i], &bits, 4);
|
|
}
|
|
}
|
|
void StoreVec(uint8_t* base, uint32_t a, const float v[3])
|
|
{
|
|
for (int i = 0; i < 3; i++)
|
|
{
|
|
uint32_t bits;
|
|
memcpy(&bits, &v[i], 4);
|
|
PPC_STORE_U32(a + i * 4, bits);
|
|
}
|
|
}
|
|
// Render entry (main loop thread, the world's step done): this step's
|
|
// particles against the last step's, and the last step's for next time.
|
|
void TrackParticles(uint8_t* base, uint32_t step)
|
|
{
|
|
s_particlesNow.clear();
|
|
ForEachParticle(base, [&](uint32_t p) {
|
|
ParticleNow n{ p, {}, {}, false };
|
|
LoadVec(base, p + kParticlePosition, n.now);
|
|
Particle& last = s_particles[p];
|
|
if (last.step + 1 == step)
|
|
{
|
|
const float dx = n.now[0] - last.pos[0], dy = n.now[1] - last.pos[1], dz = n.now[2] - last.pos[2];
|
|
if (dx * dx + dy * dy + dz * dz < kParticleMaxMove * kParticleMaxMove)
|
|
{
|
|
memcpy(n.before, last.pos, sizeof(n.before));
|
|
n.blend = true;
|
|
}
|
|
}
|
|
memcpy(last.pos, n.now, sizeof(last.pos));
|
|
last.step = step;
|
|
s_particlesNow.push_back(n);
|
|
});
|
|
}
|
|
|
|
// NFSMW_HFR_TIMELINE=<frame>,<frames> (DIAGNOSTIC): for that many frames
|
|
// from that one, a [hfrtime] line with when the frame's steps happened,
|
|
// in ms after the guest vblank before the frame's render entry: the
|
|
// entry, the last real picture played (the wait before), the in-between
|
|
// picture recorded, played (the wait after), the real one recorded. The
|
|
// command processor's swaps get lines of their own (hfr::NoteSwap).
|
|
struct Timeline
|
|
{
|
|
uint64_t from = 0, frames = 0;
|
|
};
|
|
const Timeline s_timeline = [] {
|
|
Timeline t;
|
|
if (const char* v = std::getenv("NFSMW_HFR_TIMELINE"))
|
|
{
|
|
unsigned long long a = 0, b = 0;
|
|
if (sscanf(v, "%llu,%llu", &a, &b) == 2)
|
|
t = { a, b };
|
|
}
|
|
return t;
|
|
}();
|
|
std::atomic<bool> s_timelineOn{ false };
|
|
double SinceVblankMs(int64_t vblank)
|
|
{
|
|
const int64_t now = std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now().time_since_epoch()).count();
|
|
return double(now - vblank) / 1e6;
|
|
}
|
|
|
|
// Stats for the [hfr] line (since the last).
|
|
struct Stats
|
|
{
|
|
uint64_t frames = 0, skippedNoStep = 0, skippedSteps = 0, skippedStart = 0, skippedLoad = 0, particles = 0, particlesBlended = 0;
|
|
uint64_t ownPictures = 0; // steps whose own picture was drawn between
|
|
uint64_t pictures = 0, threaded = 0, cars = 0, carCuts = 0, cameras = 0, cameraCuts = 0, waitTimeouts = 0;
|
|
double waitMs = 0.0, waitMaxMs = 0.0, waitBeforeMs = 0.0;
|
|
} s_stats;
|
|
|
|
// Limits on one step's motion beyond which it isn't blended (a cut, a reset): 200 mph is 1.5 m a step.
|
|
constexpr float kCarMaxMove = 8.0f, kCarMaxTurn = 45.0f;
|
|
constexpr float kCameraMaxMove = 8.0f, kCameraMaxTurn = 30.0f;
|
|
|
|
// Wait for the render thread to have played the frame it is playing (its
|
|
// passes, its end-of-frame pass and Present included), as sub_82441DB0
|
|
// does before the next frame's passes are recorded. Before the in-between
|
|
// picture: the last real one may still be playing, and its end-of-frame
|
|
// pass adapts the exposure (and its passes read the cars' poses) after
|
|
// this thread moves on; saving or blending before it has finished put the
|
|
// real pictures one adaptation behind the in-between ones (the whole
|
|
// picture 1-3 levels darker every other frame, run h3).
|
|
void WaitPlayed(uint8_t* base, bool before)
|
|
{
|
|
if (!PPC_LOAD_U32(kRenderThreaded))
|
|
return;
|
|
if (!before)
|
|
s_stats.threaded++;
|
|
// As long as the game's own next frame would wait (a first frame's
|
|
// pipelines can take seconds to build with a cold shader cache: M1's
|
|
// 250 ms cap put the state back while the picture was still playing).
|
|
// The cap only keeps a wedged render thread from hanging this one.
|
|
const auto t0 = std::chrono::steady_clock::now();
|
|
bool said = false;
|
|
for (int spins = 0; PPC_LOAD_U32(kFramePlaying) == 1; spins++)
|
|
{
|
|
const double ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
|
|
if (ms > 250.0 && !said)
|
|
{
|
|
said = true;
|
|
static int lines = 0;
|
|
if (lines++ < 8)
|
|
fprintf(stderr, "[hfr] render thread still playing after 250 ms (%s the in-between): recorded %u, played %u, "
|
|
"queue %u, end %u\n", before ? "before" : "after", PPC_LOAD_U32(0x82909650), PPC_LOAD_U32(0x82909654),
|
|
PPC_LOAD_U32(0x8290965C), unsigned(PPC_LOAD_U8(0x82909658)));
|
|
}
|
|
if (ms > 10'000.0)
|
|
{
|
|
s_stats.waitTimeouts++;
|
|
fprintf(stderr, "[hfr] render thread still playing after 10 s: going on without it\n");
|
|
break;
|
|
}
|
|
if (spins < 64)
|
|
std::this_thread::yield();
|
|
else
|
|
std::this_thread::sleep_for(std::chrono::microseconds(50));
|
|
}
|
|
std::atomic_thread_fence(std::memory_order_acquire);
|
|
const double ms = std::chrono::duration<double, std::milli>(std::chrono::steady_clock::now() - t0).count();
|
|
(before ? s_stats.waitBeforeMs : s_stats.waitMs) += ms;
|
|
if (!before)
|
|
s_stats.waitMaxMs = std::max(s_stats.waitMaxMs, ms);
|
|
}
|
|
|
|
void BeginInBetween(uint8_t* base, float t)
|
|
{
|
|
WaitPlayed(base, true);
|
|
if (s_writeSet == 1)
|
|
hfrdiag::WriteSetBeforeBetween(base);
|
|
s_saved.clear();
|
|
s_savedCameras.clear();
|
|
// The dynamic vertex buffer manager at 0x82909730 (sub_82446830: 3 slots,
|
|
// +0 the slot, then its write state; +0x40-0x5F the per-frame gate
|
|
// the particles' fill keys on RealTimeFrames, sub_8243B0B0, sub_8243B100,
|
|
// sub_82447450: else the real picture draws no particles). M2 put all of
|
|
// it back, so the real picture rewrote the slot the in-between picture's
|
|
// draws were still reading (the write guard stalled sub_82445198 a
|
|
// picture, 3.8 ms each: M2's run gd2). NFSMW_HFR_VBSLOT=1 (M3) puts back
|
|
// only the gate: the in-between picture takes a slot of its own.
|
|
if (s_vbSlot)
|
|
Save(base, 0x82909770, 0x20);
|
|
else
|
|
Save(base, 0x82909730, 0x60);
|
|
// The eye adaptation (sub_82223220: adapted, measured at +0 and +4)
|
|
// and the post-processing state after it, which a render writes too
|
|
// (M2's write set ws1: 0x82908AC1-AF7 and 0x82908B42-B97 left changed
|
|
// by the in-between picture).
|
|
Save(base, 0x82908AB8, 0xE8);
|
|
Save(base, 0x82A2C480, 0x50); // its luminance targets' ping-pong (sub_822234F0)
|
|
Save(base, 0x82A2D5E0, 4); // the render's clock (sub_823AFC68)
|
|
Save(base, 0x82C5E348, 4); // the camera-update counter (sub_82161000)
|
|
s_stats.pictures++;
|
|
if (s_blend)
|
|
{
|
|
for (auto& [object, car] : s_cars)
|
|
{
|
|
if (!s_blendCars || car.step != s_step)
|
|
continue; // not updated this frame (gone, or no world step)
|
|
float now[16], blended[16];
|
|
Load(base, object + kCarMatrix, now);
|
|
if (hfr::blend::WorldCut(car.before, now, kCarMaxMove, kCarMaxTurn) || !hfr::blend::World(car.before, now, t, blended))
|
|
{
|
|
s_stats.carCuts++;
|
|
continue;
|
|
}
|
|
Save(base, object + kCarMatrix, 64);
|
|
Store(base, object + kCarMatrix, blended);
|
|
s_stats.cars++;
|
|
}
|
|
for (auto& [camera, last] : s_cameras)
|
|
{
|
|
if (!s_blendCameras || last.step >= s_step || s_step - last.step > 2)
|
|
continue; // no step since (paused), or too old to be the last picture's
|
|
float now[16], blended[16];
|
|
Load(base, camera, now);
|
|
if (!memcmp(now, last.m, sizeof(now)))
|
|
continue;
|
|
if (hfr::blend::ViewCut(last.m, now, kCameraMaxMove, kCameraMaxTurn) || !hfr::blend::View(last.m, now, t, blended))
|
|
{
|
|
s_stats.cameraCuts++;
|
|
continue;
|
|
}
|
|
Save(base, camera, kCameraBytes);
|
|
s_savedCameras.insert(camera);
|
|
Store(base, camera, blended);
|
|
const uint16_t fov = PPC_LOAD_U16(camera + kCameraFov);
|
|
if (last.fov && fov && std::abs(int(fov) - int(last.fov)) < 4096)
|
|
PPC_STORE_U16(camera + kCameraFov, uint16_t(std::lround(float(last.fov) + t * float(int(fov) - int(last.fov)))));
|
|
s_stats.cameras++;
|
|
}
|
|
}
|
|
if (s_blendParticles)
|
|
for (const ParticleNow& n : s_particlesNow)
|
|
{
|
|
s_stats.particles++;
|
|
if (!n.blend)
|
|
continue;
|
|
float blended[3];
|
|
for (int i = 0; i < 3; i++)
|
|
blended[i] = n.before[i] + t * (n.now[i] - n.before[i]);
|
|
Save(base, n.address + kParticlePosition, 12);
|
|
StoreVec(base, n.address + kParticlePosition, blended);
|
|
s_stats.particlesBlended++;
|
|
}
|
|
// RenderFlares writes each car's trail ring once a render.
|
|
for (auto& [object, car] : s_cars)
|
|
if (car.step == s_step)
|
|
if (const uint32_t info = PPC_LOAD_U32(object + kCarInfo))
|
|
Save(base, info + kTrailRing, kTrailRingBytes);
|
|
s_fraction.store(t, std::memory_order_relaxed);
|
|
s_inBetween.store(true, std::memory_order_release);
|
|
}
|
|
|
|
void EndInBetween(uint8_t* base)
|
|
{
|
|
WaitPlayed(base, false);
|
|
RestoreAll(base);
|
|
s_inBetween.store(false, std::memory_order_release);
|
|
s_fraction.store(0.0f, std::memory_order_relaxed);
|
|
}
|
|
|
|
void BeginOwn(uint8_t* base, float t)
|
|
{
|
|
WaitPlayed(base, true);
|
|
s_ownSaved.clear();
|
|
s_ownCameras.clear();
|
|
auto save = [&](uint32_t address, uint32_t size) {
|
|
s_ownSaved.push_back({ address, std::vector<uint8_t>(base + address, base + address + size) });
|
|
};
|
|
if (s_blendCars)
|
|
for (auto& [object, car] : s_cars)
|
|
{
|
|
if (car.step != s_step)
|
|
continue;
|
|
float now[16], blended[16];
|
|
Load(base, object + kCarMatrix, now);
|
|
if (hfr::blend::WorldCut(car.before, now, kCarMaxMove, kCarMaxTurn) || !hfr::blend::World(car.before, now, t, blended))
|
|
continue;
|
|
save(object + kCarMatrix, 64);
|
|
Store(base, object + kCarMatrix, blended);
|
|
}
|
|
if (s_blendCameras)
|
|
for (auto& [camera, last] : s_cameras)
|
|
{
|
|
if (last.step >= s_step || s_step - last.step > 2)
|
|
continue;
|
|
float now[16], blended[16];
|
|
Load(base, camera, now);
|
|
if (!memcmp(now, last.m, sizeof(now)))
|
|
continue;
|
|
if (hfr::blend::ViewCut(last.m, now, kCameraMaxMove, kCameraMaxTurn) || !hfr::blend::View(last.m, now, t, blended))
|
|
continue;
|
|
OwnCamera c{ camera, {} };
|
|
memcpy(c.state, base + camera, sizeof(c.state));
|
|
s_ownCameras.push_back(c);
|
|
const uint16_t fov = PPC_LOAD_U16(camera + kCameraFov);
|
|
Store(base, camera, blended);
|
|
if (last.fov && fov && std::abs(int(fov) - int(last.fov)) < 4096)
|
|
PPC_STORE_U16(camera + kCameraFov, uint16_t(std::lround(float(last.fov) + t * float(int(fov) - int(last.fov)))));
|
|
// The next in-between picture starts from this step's real pose.
|
|
memcpy(last.m, now, sizeof(now));
|
|
last.fov = fov;
|
|
last.step = s_step;
|
|
}
|
|
if (s_blendParticles)
|
|
for (const ParticleNow& n : s_particlesNow)
|
|
{
|
|
if (!n.blend)
|
|
continue;
|
|
float blended[3];
|
|
for (int i = 0; i < 3; i++)
|
|
blended[i] = n.before[i] + t * (n.now[i] - n.before[i]);
|
|
save(n.address + kParticlePosition, 12);
|
|
StoreVec(base, n.address + kParticlePosition, blended);
|
|
}
|
|
s_stats.ownPictures++;
|
|
s_ownFraction.store(t, std::memory_order_release);
|
|
}
|
|
|
|
void EndOwn(uint8_t* base)
|
|
{
|
|
WaitPlayed(base, false);
|
|
for (auto it = s_ownSaved.rbegin(); it != s_ownSaved.rend(); ++it)
|
|
memcpy(base + it->address, it->bytes.data(), it->bytes.size());
|
|
s_ownSaved.clear();
|
|
for (const OwnCamera& c : s_ownCameras)
|
|
{
|
|
memcpy(base + c.camera, c.state, sizeof(c.state));
|
|
memcpy(base + c.camera + 224, c.state, sizeof(c.state));
|
|
}
|
|
s_ownCameras.clear();
|
|
s_ownFraction.store(0.0f, std::memory_order_release);
|
|
}
|
|
|
|
// Load: in-between pictures only while the command processor and the
|
|
// GPU can do a step's pictures in a step. A frame whose work outlasts
|
|
// its vblank runs two world steps the next time, and the world plays
|
|
// differently for it (M2's A/B: 39-50 such frames at the race start on
|
|
// the M1 Pro, two pictures of 9.4 ms CP and 9.3 ms GPU each, and the
|
|
// launch, Razor and the coast-down moved with them; a 60 fps run beside
|
|
// a build overran as often and moved the same way). So when kLateOfRecent
|
|
// of the last kRecent frames with in-between pictures took the command
|
|
// processor or the GPU (execution, timestamps) more than kBudgetMs, the
|
|
// frames go on without them (60 fps, still even) until a step's pictures
|
|
// at twice the real picture's cost would take less than kResumeMs for 90%
|
|
// of the last kResumeWindow frames, `hold` (2 s at first, twice as long
|
|
// when that overloads again within kRecentResume, up to 32 s). M2's run
|
|
// dm5 found single frames' costs too noisy for anything stricter: one
|
|
// cutscene start turned them off for good; its race held 120 fps evenly
|
|
// with 15.8 ms of the command processor's and 11.2 of the GPU's a step,
|
|
// so the budget is nearly the whole step. A frame with in-between
|
|
// pictures that ran two world steps (it overran) after a second of frames
|
|
// of one step each turns them off at once (two-step frames in loads,
|
|
// movies and 30 fps menus come at 60 fps too: M2's run dm7 counted them,
|
|
// and their backoff kept HFR off for the race).
|
|
// NFSMW_HFR_LOAD=0: no check.
|
|
// M2's dm11: the race that held 120 evenly without the check (dm3: 0
|
|
// overruns) went over 16.2 ms in 4 of 8 frames now and then, and was
|
|
// held at 60 for 32 s at a time. The command processor's busy time isn't
|
|
// a step's capacity to the millisecond, so cost alone turns them off only
|
|
// past what no step can hold (kBudgetMs); overruns do the rest.
|
|
constexpr double kBudgetMs = 19.0, kResumeMs = 16.0;
|
|
constexpr uint32_t kRecent = 8, kLateOfRecent = 4, kResumeWindow = 120;
|
|
std::atomic<bool> s_overran{ false }; // main loop thread: frames with them overran (twice within kOverrunWindow)
|
|
// An overrun alone doesn't count: one comes with a load's or a pipeline's
|
|
// hitch at 60 fps too, and the first frames after in-between pictures
|
|
// come back have one now and then (M2's dm13 turned them off for good
|
|
// that way, in a race that held 120 with 0 overruns when left alone:
|
|
// gd1). Two within kOverrunWindow do, and none in kResumeGrace after a
|
|
// resume.
|
|
constexpr int64_t kOverrunWindowNs = 2'000'000'000, kResumeGraceNs = 2'000'000'000;
|
|
int64_t s_lastOverrunNs = 0; // main loop thread
|
|
std::atomic<int64_t> s_resumedNs{ 0 };
|
|
// The hold doubles when they overload again within kRecentResume of
|
|
// coming back, and starts over at a lone overrun: that is as often a
|
|
// load's or a pipeline's hitch, which comes at 60 fps too (M2's dm10:
|
|
// hitches in the menus and at the race start grew the hold to 32 s, and
|
|
// the race, which held 120 evenly in dm3, waited 64 s for them).
|
|
// M3: kRecentResume 5 -> 30 s, and a lone overrun starts the hold over
|
|
// only kHoldResetNs after the last overload: a play-test race
|
|
// (2026-10-18) overloaded again 4-14 s after each of six resumes, each
|
|
// hold started over at 2 s, and it switched 12 times in 70 s.
|
|
constexpr int64_t kFirstHoldNs = 2'000'000'000, kMaxHoldNs = 16'000'000'000, kRecentResumeNs = 30'000'000'000,
|
|
kHoldResetNs = 60'000'000'000;
|
|
// M3: an in-between picture's command-processor time as a share of a
|
|
// real one's (EMA while both are drawn: the passes it leaves out make it
|
|
// 0.3-0.6): a step's two pictures while overloaded are estimated at
|
|
// (1 + share) times the real picture's cost, not twice it.
|
|
std::atomic<float> s_betweenShare{ 1.0f };
|
|
const bool s_loadCheck = [] { const char* v = std::getenv("NFSMW_HFR_LOAD"); return !(v && v[0] == '0'); }();
|
|
std::atomic<bool> s_overloaded{ false };
|
|
struct LoadState // command processor thread only
|
|
{
|
|
bool late[kRecent] = {};
|
|
uint32_t next = 0;
|
|
float costs[kResumeWindow] = {}; // overloaded: twice each frame's cost
|
|
uint32_t costCount = 0, costNext = 0;
|
|
int64_t since = 0, hold = kFirstHoldNs, resumedAt = 0, overloads = 0, overloadedAt = 0;
|
|
// The lite step (s_level 1): since when, how long before full
|
|
// in-between pictures are tried again, and when they last were.
|
|
int64_t liteSince = 0, liteHold = 0, fullTriedAt = 0;
|
|
float liteCosts[kResumeWindow] = {}; // lite: each step's estimated cost with full in-between pictures
|
|
uint32_t liteCount = 0, liteNext = 0;
|
|
} s_load;
|
|
// M3's ladder: full in-between pictures go lite at an overload, back
|
|
// to full after kFirstLiteHold of lite ones without one (twice that, at
|
|
// most kMaxLiteHold, when the full ones overload again within
|
|
// kRecentResume of the try); lite ones that overload go to none (the
|
|
// hold above), and come back lite.
|
|
constexpr int64_t kFirstLiteHoldNs = 10'000'000'000, kMaxLiteHoldNs = 120'000'000'000;
|
|
int64_t SteadyNs()
|
|
{
|
|
return std::chrono::duration_cast<std::chrono::nanoseconds>(std::chrono::steady_clock::now().time_since_epoch()).count();
|
|
}
|
|
|
|
// A step's pictures: their fractions of the way from the last step, in
|
|
// order, the last its own (1: the real picture). The display's
|
|
// (video::HfrPicturesPerStep: n pictures, i/n), NFSMW_HFR_PICTURES's or
|
|
// NFSMW_HFR_FRACTIONS's; one, the real one, while overloaded; 1/2, 1 for
|
|
// NFSMW_HFR_RENDER2.
|
|
int StepFractions(float f[4], uint32_t steps)
|
|
{
|
|
if (!s_enabled)
|
|
{
|
|
f[0] = 0.5f;
|
|
f[1] = 1.0f;
|
|
return 2;
|
|
}
|
|
if (s_overloaded.load(std::memory_order_relaxed))
|
|
{
|
|
f[0] = 1.0f;
|
|
return 1;
|
|
}
|
|
if (!s_forcedFractions.empty())
|
|
{
|
|
const std::vector<float>& step = s_forcedFractions[s_fractionCycle++ % s_forcedFractions.size()];
|
|
for (size_t i = 0; i < step.size(); i++)
|
|
f[i] = step[i];
|
|
return int(step.size());
|
|
}
|
|
const int n = s_forcedPictures ? s_forcedPictures : std::clamp(video::HfrPicturesPerStep(), 1, 4);
|
|
double period = 0;
|
|
int64_t grid = 0;
|
|
if (n == 1 && video::HfrGrid(period, grid))
|
|
{
|
|
// A picture for each refresh between the last step's last picture
|
|
// (shown at the vblank before, plus a vblank, plus kGridLeadNs)
|
|
// and this step's: 90 Hz's 2/3 | 1/3, 1, 144 Hz's 2-3 a step.
|
|
// `from` moves on a step at a time, so each step's pictures follow
|
|
// the last one's: the guest vblank before a render entry can be
|
|
// the same for two frames in a row (an entry just after it, the
|
|
// next just before the following one), and M2's run dg1 then drew
|
|
// a step's in-between picture for an earlier refresh than the
|
|
// last step's own. Back on the vblank's when that's half a step off.
|
|
// The step's own picture goes out two vblanks after the one before
|
|
// this render entry (M2's tl2: an entry 15 ms after a vblank, its
|
|
// real picture released by the vblank after next), so `from` (the
|
|
// last step's) is a vblank and kGridLeadNs past the next one: dg3
|
|
// asked for times 5 ms before the pictures were released.
|
|
const double step = video::GuestVblank().PeriodNs();
|
|
const double byVblank = double(video::LastGuestVblank()) + 2.0 * step + double(kGridLeadNs);
|
|
s_gridFrom += step * double(std::max<uint32_t>(steps, 1));
|
|
// The vblank before an entry flips between two (entries close to
|
|
// it): only further off than that is the schedule resynced.
|
|
if (!(std::fabs(s_gridFrom - byVblank) <= 1.5 * step))
|
|
s_gridFrom = byVblank;
|
|
if (const int count = hfr::grid::Pictures(double(grid), period, step, s_gridFrom, f, s_targets))
|
|
return count;
|
|
}
|
|
for (int i = 0; i < n; i++)
|
|
{
|
|
f[i] = float(i + 1) / float(n);
|
|
s_targets[i] = 0;
|
|
}
|
|
return n;
|
|
}
|
|
|
|
}
|
|
|
|
namespace
|
|
{
|
|
enum SkipPass
|
|
{
|
|
kSkipShadow = 1, kSkipView4 = 2, kSkipEnv = 4, kSkipLum = 8
|
|
};
|
|
// Which passes an in-between picture leaves out: none at first (the
|
|
// left-out passes' contents are half a step old, and M3's Studio bursts
|
|
// (s9a) found the in-between pictures up to 1 px off halfway in some NIS
|
|
// shots with the shadow and environment maps left out); the "lite" set
|
|
// while the load check finds the full pictures too heavy (a ladder: full
|
|
// in-between pictures, lite ones, none), as on the M1 Pro, whose NIS
|
|
// drew in-between pictures in 85% of its frames instead of 59% with them
|
|
// left out (run d4e). NFSMW_HFR_SKIP=<list> leaves its passes out always,
|
|
// NFSMW_HFR_LITE=<list> picks the lite set (default shadow, env, mirror;
|
|
// none: no lite step): shadow, view4 or mirror, env, lum, all, none.
|
|
uint32_t ParseSkip(const char* v)
|
|
{
|
|
const std::string s = v;
|
|
uint32_t m = 0;
|
|
if (s.find("shadow") != std::string::npos || s == "all")
|
|
m |= kSkipShadow;
|
|
if (s.find("view4") != std::string::npos || s.find("mirror") != std::string::npos || s == "all")
|
|
m |= kSkipView4;
|
|
if (s.find("env") != std::string::npos || s == "all")
|
|
m |= kSkipEnv;
|
|
if (s.find("lum") != std::string::npos)
|
|
m |= kSkipLum;
|
|
return m;
|
|
}
|
|
// Always left out by default: the luminance measurement, whose reading
|
|
// an in-between picture never uses (sub_82223308 returns the last real
|
|
// one's, and the exposure is put back): its resolve went into the target
|
|
// the real picture reads when the GPU is behind (M3's run d8a: the real
|
|
// pictures' luminance reads still stalled 6 times a second).
|
|
const uint32_t s_forcedSkip = [] { const char* v = std::getenv("NFSMW_HFR_SKIP"); return v ? ParseSkip(v) : uint32_t(kSkipLum); }();
|
|
const uint32_t s_liteSkip = [] {
|
|
const char* v = std::getenv("NFSMW_HFR_LITE");
|
|
return v ? ParseSkip(v) : uint32_t(kSkipShadow | kSkipEnv | kSkipView4);
|
|
}();
|
|
// The load check's step (command processor thread writes, main loop
|
|
// thread reads): 0 full in-between pictures, 1 lite ones (s_overloaded:
|
|
// none). And what this in-between picture leaves out (main loop thread,
|
|
// latched at each frame's render entry so a step change can't split one).
|
|
std::atomic<int> s_level{ 0 };
|
|
std::atomic<uint32_t> s_activeSkip{ 0 };
|
|
uint32_t ActiveSkip()
|
|
{
|
|
return s_forcedSkip | (s_level.load(std::memory_order_relaxed) >= 1 ? s_liteSkip : 0u);
|
|
}
|
|
std::string SkipNames(uint32_t m)
|
|
{
|
|
std::string out;
|
|
for (auto [bit, name] : { std::pair{ kSkipShadow, "the shadow maps" }, std::pair{ kSkipEnv, "the environment map" },
|
|
std::pair{ kSkipView4, "the mirror" }, std::pair{ kSkipLum, "the luminance measurement" } })
|
|
if (m & bit)
|
|
out += (out.empty() ? "" : ", ") + std::string(name);
|
|
return out.empty() ? "nothing" : out;
|
|
}
|
|
// A pass left out of an in-between picture leaves its targets as the
|
|
// real picture drew them, but the views it draws from are set up again
|
|
// from the in-between picture's cameras, and the main view looks the
|
|
// targets up through them: the shadows and what the environment map
|
|
// lights moved off halfway, 0.6-1.6 px at 1280 px in a slow NIS pan (M3
|
|
// runs s8e, s8s; s8n with nothing left out: 0.18). So those views keep
|
|
// the real picture's setup: views 13-16 (the shadow maps': sub_82454DC8
|
|
// fits 13 and sub_82456030 15 to view 1, 14 and 16 to view 2) and 18-23
|
|
// (the environment map's faces). NFSMW_HFR_KEEP_VIEWS=0: set up anyway.
|
|
constexpr uint32_t kViews = 0x82A38070, kViewBytes = 112;
|
|
const bool s_keepViews = [] { const char* v = std::getenv("NFSMW_HFR_KEEP_VIEWS"); return !(v && v[0] == '0'); }();
|
|
bool KeepView(uint32_t view)
|
|
{
|
|
if (!s_enabled || !s_keepViews || !s_inBetween.load(std::memory_order_acquire) || view < kViews || view >= kViews + 24 * kViewBytes)
|
|
return false;
|
|
const uint32_t index = (view - kViews) / kViewBytes;
|
|
const uint32_t m = s_activeSkip.load(std::memory_order_relaxed);
|
|
return ((m & kSkipShadow) && index >= 13 && index <= 16) || ((m & kSkipEnv) && index >= 18);
|
|
}
|
|
bool KeepShadowFit()
|
|
{
|
|
return s_enabled && s_keepViews && (s_activeSkip.load(std::memory_order_relaxed) & kSkipShadow) && s_inBetween.load(std::memory_order_acquire);
|
|
}
|
|
std::atomic<uint64_t> s_skipped[4];
|
|
bool Skip(uint32_t pass)
|
|
{
|
|
if (!(s_activeSkip.load(std::memory_order_relaxed) & pass) || !s_inBetween.load(std::memory_order_acquire))
|
|
return false;
|
|
s_skipped[pass == kSkipShadow ? 0 : pass == kSkipView4 ? 1 : pass == kSkipEnv ? 2 : 3].fetch_add(1, std::memory_order_relaxed);
|
|
return true;
|
|
}
|
|
}
|
|
|
|
namespace hfr
|
|
{
|
|
void Init()
|
|
{
|
|
s_enabled = settings::GetBool(settings::Id::SmoothMotion);
|
|
if (s_enabled)
|
|
fprintf(stderr, "[hfr] Smooth Motion (NFSMW_HFR): each world step drawn as often as the display shows pictures between two "
|
|
"guest vblanks, first between the last step and this one; in-between pictures leave out %s (NFSMW_HFR_SKIP), and "
|
|
"%s more while the load check finds them too heavy (NFSMW_HFR_LITE)\n",
|
|
SkipNames(s_forcedSkip).c_str(), SkipNames(s_liteSkip & ~s_forcedSkip).c_str());
|
|
const char* v = std::getenv("NFSMW_HFR_RENDER2");
|
|
s_render2 = s_enabled || (v && v[0] == '1');
|
|
if (!s_enabled && s_render2)
|
|
fprintf(stderr, "[hfr] NFSMW_HFR_RENDER2=1: the render (sub_823AFC68) runs twice a frame (DIAGNOSTIC)\n");
|
|
}
|
|
bool Enabled() { return s_enabled; }
|
|
bool Render2() { return s_render2; }
|
|
bool InBetween() { return s_inBetween.load(std::memory_order_acquire); }
|
|
float InBetweenFraction() { return s_fraction.load(std::memory_order_relaxed); }
|
|
float OwnFraction() { return s_ownFraction.load(std::memory_order_acquire); }
|
|
int64_t PictureTarget() { return s_target.load(std::memory_order_relaxed); }
|
|
|
|
void NoteFrameCost(double cpMs, double gpuMs, bool hadBetween)
|
|
{
|
|
if (!s_enabled || !s_loadCheck)
|
|
return;
|
|
LoadState& l = s_load;
|
|
const int64_t now = SteadyNs();
|
|
const double cost = std::max(cpMs, gpuMs);
|
|
if (!s_overloaded.load(std::memory_order_relaxed))
|
|
{
|
|
const bool overran = s_overran.exchange(false, std::memory_order_relaxed);
|
|
if (!hadBetween && !overran)
|
|
return;
|
|
l.late[l.next++ % kRecent] = cost > kBudgetMs;
|
|
uint32_t late = 0;
|
|
for (bool b : l.late)
|
|
late += b;
|
|
if (late < kLateOfRecent && !overran)
|
|
{
|
|
// Lite: full ones again once a step's two full pictures look
|
|
// affordable (this step's cost, its in-between picture's
|
|
// share of a real one's swapped for a whole one), or as a try
|
|
// after the lite hold without an overload.
|
|
if (s_level.load(std::memory_order_relaxed) == 1)
|
|
{
|
|
const double share = s_betweenShare.load(std::memory_order_relaxed);
|
|
l.liteCosts[l.liteNext++ % kResumeWindow] = float(cost * 2.0 / (1.0 + share));
|
|
l.liteCount = std::min(l.liteCount + 1, kResumeWindow);
|
|
float p90 = 1e9f;
|
|
if (l.liteCount == kResumeWindow)
|
|
{
|
|
float sorted[kResumeWindow];
|
|
std::copy(std::begin(l.liteCosts), std::end(l.liteCosts), sorted);
|
|
std::nth_element(sorted, sorted + kResumeWindow * 9 / 10, sorted + kResumeWindow);
|
|
p90 = sorted[kResumeWindow * 9 / 10];
|
|
}
|
|
const bool affordable = p90 < kResumeMs && now - l.liteSince >= kFirstHoldNs;
|
|
if (affordable || now - l.liteSince >= l.liteHold)
|
|
{
|
|
s_level.store(0, std::memory_order_relaxed);
|
|
l.fullTriedAt = now;
|
|
s_resumedNs.store(now, std::memory_order_relaxed); // the overrun grace after a change
|
|
fprintf(stderr, "[hfr] full in-between pictures again: %s (lite for %.0f s without an overload; two full pictures a "
|
|
"step would have taken %.1f ms or less in 90%% of the last %u frames)\n",
|
|
affordable ? "affordable" : "trying", double(now - l.liteSince) / 1e9, p90, kResumeWindow);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
// Full ones too heavy: lite ones first (the lite set left out).
|
|
if (s_level.load(std::memory_order_relaxed) == 0 && (s_liteSkip & ~s_forcedSkip))
|
|
{
|
|
if (l.fullTriedAt && now - l.fullTriedAt < kRecentResumeNs)
|
|
l.liteHold = std::min(l.liteHold * 2, kMaxLiteHoldNs);
|
|
else if (!l.liteHold || now - l.liteSince >= kHoldResetNs)
|
|
l.liteHold = kFirstLiteHoldNs;
|
|
l.liteSince = now;
|
|
l.liteCount = l.liteNext = 0;
|
|
std::fill(std::begin(l.late), std::end(l.late), false);
|
|
s_level.store(1, std::memory_order_relaxed);
|
|
s_resumedNs.store(now, std::memory_order_relaxed); // the overrun grace after a change: d8a dropped lite to 60 5 s after
|
|
fprintf(stderr, "[hfr] lite in-between pictures for now: %s (the last frame: %.1f ms of the command processor, %.1f of the "
|
|
"GPU); they leave out %s, full ones again after %.0f s without an overload\n",
|
|
overran ? "a frame with full ones ran two world steps" : std::format("{} of the last {} frames with full ones took more than "
|
|
"{:.1f} ms of the command processor or the GPU", late, kRecent, kBudgetMs).c_str(),
|
|
cpMs, gpuMs, SkipNames(s_liteSkip & ~s_forcedSkip).c_str(), double(l.liteHold) / 1e9);
|
|
return;
|
|
}
|
|
// Again soon after a resume: twice the hold (at most kMaxHold); a
|
|
// lone overrun (a load's, a pipeline's hitch) a while after the
|
|
// last overload starts it over.
|
|
if (l.resumedAt && now - l.resumedAt < kRecentResumeNs)
|
|
l.hold = std::min(l.hold * 2, kMaxHoldNs);
|
|
else if (overran && now - l.overloadedAt >= kHoldResetNs)
|
|
l.hold = kFirstHoldNs;
|
|
l.overloadedAt = now;
|
|
std::fill(std::begin(l.late), std::end(l.late), false);
|
|
l.since = now;
|
|
l.costCount = l.costNext = 0;
|
|
l.overloads++;
|
|
s_overloaded.store(true, std::memory_order_relaxed);
|
|
fprintf(stderr, "[hfr] no in-between pictures for now: %s (the last frame: %.1f ms of the command processor, %.1f of the GPU); "
|
|
"back when two pictures would take < %.1f ms in 90%% of %u frames (an in-between picture %.2f of a real one), after %.0f s at least (overload %lld)\n",
|
|
overran ? "a frame with them ran two world steps" : std::format("{} of the last {} frames with them took more than {:.1f} ms "
|
|
"of the command processor or the GPU", late, kRecent, kBudgetMs).c_str(),
|
|
cpMs, gpuMs, kResumeMs, kResumeWindow, double(s_betweenShare.load(std::memory_order_relaxed)), double(l.hold) / 1e9,
|
|
(long long)l.overloads);
|
|
return;
|
|
}
|
|
// Overloaded: one picture a step; with its in-between picture, the
|
|
// step would take (1 + its measured share) times this.
|
|
l.costs[l.costNext++ % kResumeWindow] = float((1.0 + s_betweenShare.load(std::memory_order_relaxed)) * cost);
|
|
l.costCount = std::min(l.costCount + 1, kResumeWindow);
|
|
if (l.costCount < kResumeWindow || now - l.since < l.hold)
|
|
return;
|
|
float sorted[kResumeWindow];
|
|
std::copy(std::begin(l.costs), std::end(l.costs), sorted);
|
|
std::nth_element(sorted, sorted + kResumeWindow * 9 / 10, sorted + kResumeWindow);
|
|
const float p90 = sorted[kResumeWindow * 9 / 10];
|
|
// Twice a real picture's cost overstates a step's two (M2's dm8: the
|
|
// race that held 120 evenly in dm3 never looked affordable): after
|
|
// twice the hold, try them anyway (a try that overloads again within
|
|
// kRecentResume doubles the hold).
|
|
const bool tryAnyway = now - l.since >= 2 * l.hold;
|
|
if (p90 >= kResumeMs && !tryAnyway)
|
|
return;
|
|
l.resumedAt = now;
|
|
s_resumedNs.store(now, std::memory_order_relaxed);
|
|
// Back lite (when there is a lite step), full ones after its hold.
|
|
if (s_liteSkip & ~s_forcedSkip)
|
|
{
|
|
s_level.store(1, std::memory_order_relaxed);
|
|
l.liteSince = now;
|
|
l.liteCount = l.liteNext = 0;
|
|
if (!l.liteHold)
|
|
l.liteHold = kFirstLiteHoldNs;
|
|
}
|
|
s_overloaded.store(false, std::memory_order_relaxed);
|
|
fprintf(stderr, "[hfr] in-between pictures again%s: %s (two pictures a step would have taken %.1f ms or less in 90%% of the last %u "
|
|
"frames)\n", s_level.load(std::memory_order_relaxed) == 1 ? ", lite" : "", p90 < kResumeMs ? "affordable" : "trying", p90,
|
|
kResumeWindow);
|
|
}
|
|
|
|
void NoteSwap(float between)
|
|
{
|
|
if (s_timelineOn.load(std::memory_order_relaxed))
|
|
fprintf(stderr, "[hfrtime] swap %s at %.2f ms after the guest vblank\n", between != 0.0f ? "in-between" : "real",
|
|
SinceVblankMs(video::LastGuestVblank()));
|
|
}
|
|
}
|
|
|
|
// The frame's render entry (sub_823AFC68: skips while [0x82A2CC08] > 0, else
|
|
// times and calls the render frame sub_82441050). Called from the frame
|
|
// (sub_823AFE20) after the world steps and the cameras' and cars' updates.
|
|
extern "C" PPC_FUNC(__imp__sub_823AFC68);
|
|
PPC_FUNC(sub_823AFC68)
|
|
{
|
|
s_writeSet = 0;
|
|
float ownFraction = 1.0f; // < 1: this step's own picture drawn between (no real picture)
|
|
bool drew = false; // in-between (or own) pictures this frame
|
|
int64_t lastTarget = 0; // the step's last picture's refresh (a grid's; 0: the usual way)
|
|
const int64_t vblank = video::LastGuestVblank();
|
|
double times[5] = { SinceVblankMs(vblank), -1, -1, -1, -1 };
|
|
const bool timeline = s_timeline.frames && s_frameCount + 1 >= s_timeline.from && s_frameCount + 1 < s_timeline.from + s_timeline.frames;
|
|
s_timelineOn.store(timeline, std::memory_order_relaxed);
|
|
if (s_render2 && PPC_LOAD_U32(kRenderSkip) == 0)
|
|
{
|
|
s_step = Step(base);
|
|
s_writeSet = s_enabled ? hfrdiag::WriteSetPhase(++s_frameCount) : 0;
|
|
// An overrun: this game frame (the one right after the last drawn)
|
|
// ran two world steps or more, after a second of frames of one
|
|
// (frame_time.cpp's counts: a frame whose render the game skipped,
|
|
// [0x82A2CC08], left the step count two apart without an overrun:
|
|
// M2's run dm12 took those for overruns).
|
|
if (s_enabled)
|
|
{
|
|
const game::FrameTimeStats ft = game::GetFrameTimeStats();
|
|
const bool next = ft.frames == s_lastFrames + 1, multi = ft.multiStep != s_lastMultiStep;
|
|
if (next && multi && s_lastDrewBetween && s_steadyFrames >= 60)
|
|
{
|
|
const int64_t now = SteadyNs();
|
|
if (now - s_resumedNs.load(std::memory_order_relaxed) >= kResumeGraceNs)
|
|
{
|
|
if (s_lastOverrunNs && now - s_lastOverrunNs < kOverrunWindowNs)
|
|
s_overran.store(true, std::memory_order_relaxed);
|
|
s_lastOverrunNs = now;
|
|
}
|
|
}
|
|
s_steadyFrames = next && !multi && ft.noStep == s_lastNoStep ? s_steadyFrames + 1 : 0;
|
|
s_lastFrames = ft.frames;
|
|
s_lastMultiStep = ft.multiStep;
|
|
s_lastNoStep = ft.noStep;
|
|
}
|
|
if (s_enabled && s_blendParticles)
|
|
TrackParticles(base, s_step);
|
|
float fractions[4];
|
|
const int pictures = StepFractions(fractions, s_step - s_lastRealStep);
|
|
s_activeSkip.store(s_enabled ? ActiveSkip() : 0u, std::memory_order_relaxed); // this frame's in-between pictures leave these out
|
|
const bool ownBetween = fractions[pictures - 1] < 0.999f;
|
|
if (s_enabled && pictures == 1 && !ownBetween)
|
|
{
|
|
s_stats.frames++;
|
|
if (s_overloaded.load(std::memory_order_relaxed))
|
|
s_stats.skippedLoad++;
|
|
}
|
|
if (pictures > 1 || ownBetween)
|
|
{
|
|
s_stats.frames++;
|
|
// One world step since the last real picture: in-between pictures
|
|
// of it. None (paused, or a frame given no vblank: it would show
|
|
// the last picture again) or several (a hitch: the frame is late
|
|
// already) gets the real picture only.
|
|
const uint32_t steps = s_step - s_lastRealStep;
|
|
// Nor the first frames the render thread plays (a race or a
|
|
// cutscene starting: they build what they draw first).
|
|
s_threadedFrames = PPC_LOAD_U32(kRenderThreaded) ? s_threadedFrames + 1 : 0;
|
|
if (s_threadedFrames == 1 || s_threadedFrames == 2)
|
|
s_stats.skippedStart++;
|
|
else if (s_writeSet == 2 || s_writeSet == 3)
|
|
{
|
|
// NFSMW_HFR_WRITESET's control frame (a real render alone) and noise frame.
|
|
WaitPlayed(base, true);
|
|
if (s_writeSet == 2)
|
|
hfrdiag::WriteSetControlBefore(base);
|
|
else
|
|
hfrdiag::WriteSetNoise(base);
|
|
}
|
|
else if (steps == 1 || !s_enabled)
|
|
{
|
|
if (s_enabled && ownBetween)
|
|
ownFraction = fractions[pictures - 1];
|
|
lastTarget = s_targets[pictures - 1];
|
|
drew = pictures > 1 || ownFraction < 1.0f;
|
|
for (int i = 0; i + 1 < pictures; i++)
|
|
{
|
|
const float t = fractions[i];
|
|
if (s_enabled)
|
|
{
|
|
BeginInBetween(base, t);
|
|
// After the last picture has been played: its swap reads the target too.
|
|
s_target.store(s_targets[i], std::memory_order_relaxed);
|
|
times[1] = SinceVblankMs(vblank);
|
|
}
|
|
else
|
|
{
|
|
WaitPlayed(base, true);
|
|
s_fraction.store(t, std::memory_order_relaxed);
|
|
s_inBetween.store(true, std::memory_order_release);
|
|
}
|
|
__imp__sub_823AFC68(ctx, base);
|
|
times[2] = SinceVblankMs(vblank);
|
|
if (s_enabled)
|
|
{
|
|
EndInBetween(base);
|
|
times[3] = SinceVblankMs(vblank);
|
|
if (s_writeSet == 1)
|
|
hfrdiag::WriteSetAfterBetween(base);
|
|
}
|
|
else
|
|
{
|
|
WaitPlayed(base, false);
|
|
s_inBetween.store(false, std::memory_order_release);
|
|
}
|
|
}
|
|
}
|
|
else if (steps == 0)
|
|
s_stats.skippedNoStep++;
|
|
else
|
|
s_stats.skippedSteps++;
|
|
}
|
|
s_lastRealStep = s_step;
|
|
s_lastDrewBetween = drew;
|
|
}
|
|
if (ownFraction < 1.0f)
|
|
BeginOwn(base, ownFraction);
|
|
// The step's own picture's refresh. Its swap is played after this thread
|
|
// has moved on, so the value stays until the next picture's is set, each
|
|
// time after the last picture has been played (M2's dg1/dg2 lost it: the
|
|
// own pictures went out at the vblank's time instead of their refresh).
|
|
if (lastTarget != s_target.load(std::memory_order_relaxed))
|
|
{
|
|
if (ownFraction >= 1.0f)
|
|
WaitPlayed(base, true);
|
|
s_target.store(lastTarget, std::memory_order_relaxed);
|
|
}
|
|
__imp__sub_823AFC68(ctx, base);
|
|
if (ownFraction < 1.0f)
|
|
EndOwn(base);
|
|
times[4] = SinceVblankMs(vblank);
|
|
if (timeline)
|
|
fprintf(stderr, "[hfrtime] frame %llu step %u: entry %.2f, last real played %.2f, in-between recorded %.2f, played %.2f, "
|
|
"real recorded %.2f (ms after the guest vblank)\n", (unsigned long long)s_frameCount, s_step, times[0], times[1],
|
|
times[2], times[3], times[4]);
|
|
if (s_writeSet == 1 || s_writeSet == 2)
|
|
{
|
|
WaitPlayed(base, false);
|
|
if (s_writeSet == 1)
|
|
hfrdiag::WriteSetAfterReal(base);
|
|
else
|
|
hfrdiag::WriteSetControlAfter(base);
|
|
}
|
|
hfrdiag::AfterRenderEntry(base);
|
|
// M3 (the load check's signal): how long the frame's render took on this
|
|
// thread (its in-between pictures and their waits included), and the
|
|
// main loop's frame (render entry to render entry), in frames with
|
|
// in-between pictures and without. The loop paces itself on the swaps
|
|
// the command processor releases at vblanks, not on the vblank (game/
|
|
// frame_time.cpp), so the render's share of the frame is the margin.
|
|
static std::vector<float> s_renderMs[2], s_frameMs[2];
|
|
static int64_t s_lastEntryNs = 0;
|
|
{
|
|
const int64_t nowNs = SteadyNs(), entryNs = nowNs - int64_t((times[4] - times[0]) * 1e6);
|
|
if (s_enabled && PPC_LOAD_U32(kRenderSkip) == 0)
|
|
{
|
|
s_renderMs[drew ? 1 : 0].push_back(float(times[4] - times[0]));
|
|
if (s_lastEntryNs)
|
|
s_frameMs[drew ? 1 : 0].push_back(float(double(entryNs - s_lastEntryNs) / 1e6));
|
|
}
|
|
s_lastEntryNs = entryNs;
|
|
}
|
|
// A line every 10 s of frames.
|
|
if (s_enabled && s_stats.frames >= 600)
|
|
{
|
|
std::string share;
|
|
for (int k = 1; k >= 0; k--)
|
|
{
|
|
std::vector<float>& r = s_renderMs[k];
|
|
std::vector<float>& f = s_frameMs[k];
|
|
if (r.empty())
|
|
continue;
|
|
std::sort(r.begin(), r.end());
|
|
std::sort(f.begin(), f.end());
|
|
share += std::format("{} {}: the render median {:.1f} ms, p90 {:.1f}, of frames median {:.1f}, p90 {:.1f}{}",
|
|
k ? "with in-between pictures" : "without", r.size(), r[r.size() / 2], r[r.size() * 9 / 10], f.empty() ? 0.0f : f[f.size() / 2],
|
|
f.empty() ? 0.0f : f[f.size() * 9 / 10], k ? "; " : "");
|
|
r.clear();
|
|
f.clear();
|
|
}
|
|
if (!share.empty())
|
|
fprintf(stderr, "[hfr] this thread's frames: %s\n", share.c_str());
|
|
fprintf(stderr, "[hfr] %llu frames, %llu in-between pictures, %llu steps drawn only between (none in %llu frames without a world step, %llu with several, %llu starting "
|
|
"the render thread, %llu for the load; "
|
|
"%llu on the render thread: waited %.2f ms each before, %.2f after, max %.2f, %llu timeouts): "
|
|
"%.2f cars blended a picture (%llu cuts), %.2f cameras (%llu cuts), %.1f particles of %.1f; tracking %zu cars, %zu cameras\n",
|
|
(unsigned long long)s_stats.frames, (unsigned long long)s_stats.pictures, (unsigned long long)s_stats.ownPictures,
|
|
(unsigned long long)s_stats.skippedNoStep,
|
|
(unsigned long long)s_stats.skippedSteps, (unsigned long long)s_stats.skippedStart, (unsigned long long)s_stats.skippedLoad,
|
|
(unsigned long long)s_stats.threaded,
|
|
s_stats.threaded ? s_stats.waitBeforeMs / double(s_stats.threaded) : 0.0,
|
|
s_stats.threaded ? s_stats.waitMs / double(s_stats.threaded) : 0.0, s_stats.waitMaxMs, (unsigned long long)s_stats.waitTimeouts,
|
|
s_stats.pictures ? double(s_stats.cars) / double(s_stats.pictures) : 0.0, (unsigned long long)s_stats.carCuts,
|
|
s_stats.pictures ? double(s_stats.cameras) / double(s_stats.pictures) : 0.0, (unsigned long long)s_stats.cameraCuts,
|
|
s_stats.pictures ? double(s_stats.particlesBlended) / double(s_stats.pictures) : 0.0,
|
|
s_stats.pictures ? double(s_stats.particles) / double(s_stats.pictures) : 0.0, s_cars.size(), s_cameras.size());
|
|
s_stats = {};
|
|
// Forget what wasn't seen for a few seconds (render objects are freed and reused).
|
|
const uint32_t now = s_step;
|
|
std::erase_if(s_cars, [&](const auto& kv) { return now - kv.second.step > 300; });
|
|
std::erase_if(s_cameras, [&](const auto& kv) { return now - kv.second.step > 300; });
|
|
std::erase_if(s_particles, [&](const auto& kv) { return now - kv.second.step > 2; });
|
|
}
|
|
}
|
|
|
|
// A car's render update: copies the body's matrix (its pointer at +52) to
|
|
// +816, deriving the render's velocities from the change (dt in f1).
|
|
extern "C" PPC_FUNC(__imp__sub_824E9C38);
|
|
PPC_FUNC(sub_824E9C38)
|
|
{
|
|
if (!s_enabled)
|
|
return __imp__sub_824E9C38(ctx, base);
|
|
const uint32_t object = ctx.r3.u32;
|
|
Car& car = s_cars[object];
|
|
Load(base, object + kCarMatrix, car.before);
|
|
__imp__sub_824E9C38(ctx, base);
|
|
Load(base, object + kCarMatrix, car.after);
|
|
car.step = Step(base);
|
|
}
|
|
|
|
// A view setup: records the camera each view of the real picture used.
|
|
extern "C" PPC_FUNC(__imp__sub_8243EAC0);
|
|
PPC_FUNC(sub_8243EAC0)
|
|
{
|
|
const uint32_t view = ctx.r4.u32;
|
|
// M3: an in-between picture that leaves a pass out keeps that pass's
|
|
// views as the real picture set them up (KeepView).
|
|
if (KeepView(view))
|
|
return;
|
|
if (s_enabled && !s_inBetween.load(std::memory_order_acquire) && s_ownFraction.load(std::memory_order_acquire) == 0.0f && view)
|
|
if (const uint32_t camera = PPC_LOAD_U32(view + 64))
|
|
{
|
|
Camera& c = s_cameras[camera];
|
|
Load(base, camera, c.m);
|
|
c.fov = PPC_LOAD_U16(camera + kCameraFov);
|
|
c.step = Step(base);
|
|
}
|
|
hfrdiag::OnViewSetup(base, view);
|
|
__imp__sub_8243EAC0(ctx, base);
|
|
}
|
|
|
|
// A camera's update (sub_82161000: keeps its last pose at +224 and derives
|
|
// its motion from the change over dt, f1; dt kept at +676). During an
|
|
// in-between picture the camera is put back afterwards, and it is given t of
|
|
// the dt: it moved t of a step since the last real picture, so its velocity
|
|
// (the motion blur's) is the step's, not t of it (run h5: the in-between
|
|
// pictures' road streaks in a cutscene alternated by up to 14 levels).
|
|
extern "C" PPC_FUNC(__imp__sub_82161000);
|
|
PPC_FUNC(sub_82161000)
|
|
{
|
|
if (s_enabled && s_inBetween.load(std::memory_order_acquire) && ctx.r3.u32)
|
|
{
|
|
if (s_savedCameras.insert(ctx.r3.u32).second)
|
|
Save(base, ctx.r3.u32, kCameraBytes);
|
|
if (s_blendCameras)
|
|
ctx.f1.f64 *= s_fraction.load(std::memory_order_relaxed);
|
|
}
|
|
else if (const float own = s_ownFraction.load(std::memory_order_acquire); s_enabled && own > 0.0f && s_blendCameras)
|
|
ctx.f1.f64 *= own;
|
|
__imp__sub_82161000(ctx, base);
|
|
}
|
|
|
|
// D3D's swap sync (sub_82598670, from the swap sub_82598DD0, before the
|
|
// packet VdSwap writes): with the device's presentation interval [+13772] at
|
|
// 1 it has the command processor raise an interrupt whose callback
|
|
// (sub_825985A0) sets the vblanks to wait, then wait for them (WAIT_REG_MEM):
|
|
// the swap goes out at the first vblank after the command processor reaches
|
|
// it. An in-between picture's swap waits for none, as with
|
|
// D3DPRESENT_INTERVAL_IMMEDIATE (0x80000000: the function then emits no
|
|
// packets), so the guest vblank, the game's frame clock, still releases one
|
|
// swap a step: the real picture's.
|
|
extern "C" PPC_FUNC(__imp__sub_82598670);
|
|
PPC_FUNC(sub_82598670)
|
|
{
|
|
if (!s_inBetween.load(std::memory_order_acquire))
|
|
return __imp__sub_82598670(ctx, base);
|
|
const uint32_t device = ctx.r3.u32;
|
|
const uint32_t interval = PPC_LOAD_U32(device + kDevicePresentInterval);
|
|
PPC_STORE_U32(device + kDevicePresentInterval, 0x80000000u);
|
|
__imp__sub_82598670(ctx, base);
|
|
PPC_STORE_U32(device + kDevicePresentInterval, interval);
|
|
}
|
|
|
|
// D3D's command buffers (sub_82597FF0, device creation: the parameters at r4,
|
|
// D3DRING_BUFFER_PARAMETERS-shaped: +4 the primary ring's size (0: 32 KB),
|
|
// +8 its memory, +12 the secondary buffer's size (0: 2 MB), +16 its memory,
|
|
// +20 the secondary's segments (0: 32)). The render thread can write
|
|
// commands only as far ahead of the command processor as the secondary
|
|
// buffer holds, and the command processor stops at each real picture's
|
|
// vblank wait: M2's run tl1 found the render thread spinning for ring space
|
|
// (sub_82597070 -> sub_82597498) through the in-between picture's playback,
|
|
// until the vblank (12 ms of every 16.7 ms frame the main loop waited for
|
|
// it). With HFR on, a step's two pictures need twice the room: the
|
|
// secondary buffer is made kHfrSecondaryFactor times larger, in as many more
|
|
// segments of the same size (when D3D allocates it itself: no memory given).
|
|
// NFSMW_HFR_SECONDARY=<n> sets the factor (1: the game's own).
|
|
extern "C" PPC_FUNC(__imp__sub_82597FF0);
|
|
PPC_FUNC(sub_82597FF0)
|
|
{
|
|
const uint32_t params = ctx.r4.u32;
|
|
if (params)
|
|
{
|
|
const uint32_t primary = PPC_LOAD_U32(params + 4), secondary = PPC_LOAD_U32(params + 12);
|
|
const uint32_t secondaryMemory = PPC_LOAD_U32(params + 16), segments = PPC_LOAD_U32(params + 20);
|
|
fprintf(stderr, "[hfr] D3D command buffers: primary %u bytes%s, secondary %u bytes in %u segments%s\n", primary ? primary : 32768u,
|
|
PPC_LOAD_U32(params + 8) ? " (the game's memory)" : "", secondary ? secondary : 2u << 20, segments ? segments : 32u,
|
|
secondaryMemory ? " (the game's memory)" : "");
|
|
static const uint32_t factor = [] {
|
|
const char* v = std::getenv("NFSMW_HFR_SECONDARY");
|
|
return v ? uint32_t(std::clamp(std::atoi(v), 1, 8)) : 4u;
|
|
}();
|
|
if (s_enabled && factor > 1 && !secondaryMemory)
|
|
{
|
|
const uint32_t size = (secondary ? secondary : 2u << 20) * factor, count = (segments ? segments : 32u) * factor;
|
|
PPC_STORE_U32(params + 12, size);
|
|
PPC_STORE_U32(params + 20, count);
|
|
fprintf(stderr, "[hfr] D3D's secondary command buffer made %u times larger for two pictures a step: %u bytes in %u segments\n",
|
|
factor, size, count);
|
|
}
|
|
// The primary ring too (NFSMW_HFR_PRIMARY=<n>, default 4; 1: the game's).
|
|
static const uint32_t primaryFactor = [] {
|
|
const char* v = std::getenv("NFSMW_HFR_PRIMARY");
|
|
return v ? uint32_t(std::clamp(std::atoi(v), 1, 8)) : 4u;
|
|
}();
|
|
if (s_enabled && primaryFactor > 1 && !PPC_LOAD_U32(params + 8))
|
|
{
|
|
const uint32_t size = (primary ? primary : 32768u) * primaryFactor;
|
|
PPC_STORE_U32(params + 4, size);
|
|
fprintf(stderr, "[hfr] D3D's primary ring made %u times larger: %u bytes\n", primaryFactor, size);
|
|
}
|
|
}
|
|
__imp__sub_82597FF0(ctx, base);
|
|
}
|
|
|
|
// The eye adaptation's luminance readback (sub_82223308, from sub_822234F0:
|
|
// the average of a 4 KB luminance target the GPU resolved, in f1). An
|
|
// in-between picture's would read it right after the last real picture's
|
|
// resolve, and the write guard stalled that read on the GPU (M2's run gd1:
|
|
// 237 of 400 logged stalls, 3-6 ms each; dm3's race 0.89 stalls and 4.07 ms
|
|
// stalled a picture against 0.11 ms a frame at 60). Its result is put back
|
|
// with the exposure after the picture anyway: an in-between picture uses
|
|
// the last real one's.
|
|
// M3: a real picture's own read waits too when the GPU hasn't resolved
|
|
// the target yet: at 120 fps the GPU is behind by the in-between picture's
|
|
// work, and the guard stalled the render thread there 2.5 ms a second on the
|
|
// M1 Pro (M3's run d4a: 141-180 stalls of 3-4 ms in 200 s; 33 at 60 fps). With
|
|
// Smooth Motion the real picture then reads the last real picture's target
|
|
// (a frame old: the exposure moves 2% a frame toward it, which doesn't show).
|
|
// NFSMW_HFR_STALE_LUMINANCE=0: it waits, as before.
|
|
extern "C" PPC_FUNC(__imp__sub_82223308);
|
|
PPC_FUNC(sub_82223308)
|
|
{
|
|
static double last = 0.0; // the last real picture's (whichever thread plays it)
|
|
if (s_enabled && s_inBetween.load(std::memory_order_acquire))
|
|
{
|
|
ctx.f1.f64 = last;
|
|
return;
|
|
}
|
|
static const bool staleOk = [] { const char* v = std::getenv("NFSMW_HFR_STALE_LUMINANCE"); return !(v && v[0] == '0'); }();
|
|
// The game reads the target it has just resolved, of two it takes in
|
|
// turn: while the GPU is still on its way there (run d5a: 130-450 of
|
|
// 600 real pictures), the other one, which the last real picture
|
|
// resolved and the GPU has finished, is read instead. Failing that the
|
|
// last reading, at most kStaleReadings frames in a row.
|
|
constexpr int kStaleReadings = 3;
|
|
static int stale = 0;
|
|
static uint32_t targets[2] = {}; // the last two targets read (most recent first)
|
|
const uint32_t target = ctx.r3.u32;
|
|
if (target != targets[0])
|
|
{
|
|
targets[1] = targets[0];
|
|
targets[0] = target;
|
|
}
|
|
if (s_enabled && staleOk && writewatch::GpuWritePending(target & 0x1FFFFFFF, 4096))
|
|
{
|
|
const uint32_t other = targets[1];
|
|
if (other && other != target && !writewatch::GpuWritePending(other & 0x1FFFFFFF, 4096))
|
|
{
|
|
s_staleLuminance.fetch_add(1, std::memory_order_relaxed);
|
|
ctx.r3.u32 = other;
|
|
__imp__sub_82223308(ctx, base);
|
|
last = ctx.f1.f64;
|
|
stale = 0;
|
|
return;
|
|
}
|
|
if (stale < kStaleReadings)
|
|
{
|
|
stale++;
|
|
s_staleLuminance.fetch_add(1, std::memory_order_relaxed);
|
|
ctx.f1.f64 = last;
|
|
return;
|
|
}
|
|
}
|
|
stale = 0;
|
|
__imp__sub_82223308(ctx, base);
|
|
last = ctx.f1.f64;
|
|
}
|
|
|
|
// M3 (the in-between picture's cost): render passes it can leave out. The
|
|
// frame's render (sub_824454F8) prepares the views (sub_82440728), then draws
|
|
// the sun's shadow maps (sub_824439B0: worldShadows, worldModelShadows,
|
|
// smackableShadows; views 13-14), view 4 and 5's pass (sub_82444178: world and
|
|
// cars), the environment map's faces (sub_82444520: views 18-23, each while
|
|
// its enable byte, +8, is set), and the main view (sub_82443088). A pass left
|
|
// out of an in-between picture leaves its targets as the last real picture
|
|
// drew them, and the main view samples those. Left out while the load
|
|
// check holds the lite step (s_level, s_liteSkip), or always (s_forcedSkip);
|
|
// counted on the [hfr] command processor line. Measured on the Studio's NIS (M3 runs s3a-s3e): an
|
|
// in-between picture's command processor 5.0 ms, 3.2 without the shadow
|
|
// maps, 3.8 without the environment map, 4.5 without the mirror, 1.6
|
|
// without all three.
|
|
extern "C" PPC_FUNC(__imp__sub_824439B0);
|
|
PPC_FUNC(sub_824439B0)
|
|
{
|
|
if (Skip(kSkipShadow))
|
|
return;
|
|
__imp__sub_824439B0(ctx, base);
|
|
}
|
|
|
|
// The shadow views' fit to the player's view (render setup sub_82440F98:
|
|
// sub_82454DC8 sets up view 13 or 14 from view 1 or 2, sub_82456030 view 15
|
|
// or 16): kept from the real picture with the shadow maps (KeepView).
|
|
extern "C" PPC_FUNC(__imp__sub_82454DC8);
|
|
PPC_FUNC(sub_82454DC8)
|
|
{
|
|
if (KeepShadowFit())
|
|
return;
|
|
__imp__sub_82454DC8(ctx, base);
|
|
}
|
|
|
|
extern "C" PPC_FUNC(__imp__sub_82456030);
|
|
PPC_FUNC(sub_82456030)
|
|
{
|
|
if (KeepShadowFit())
|
|
return;
|
|
__imp__sub_82456030(ctx, base);
|
|
}
|
|
|
|
extern "C" PPC_FUNC(__imp__sub_82444178);
|
|
PPC_FUNC(sub_82444178)
|
|
{
|
|
if (Skip(kSkipView4))
|
|
return;
|
|
__imp__sub_82444178(ctx, base);
|
|
}
|
|
|
|
// The eye adaptation's luminance measurement (sub_822234F0: downsamples the
|
|
// picture, resolves it into one of two small targets, reads the other back,
|
|
// sub_82223308, and adapts the exposure, sub_82223220). An in-between picture's
|
|
// reading is the last real one's anyway, and its exposure is put back after it;
|
|
// and M2's run gd2 found the real picture's readback stalled 182 times (3.4 ms
|
|
// each) where the game at 60 stalls there a few times: the guess is that it
|
|
// waits for the in-between picture's resolve, in the same pages.
|
|
// NFSMW_HFR_SKIP=lum (M3): none in in-between pictures.
|
|
extern "C" PPC_FUNC(__imp__sub_822234F0);
|
|
PPC_FUNC(sub_822234F0)
|
|
{
|
|
if (Skip(kSkipLum))
|
|
return;
|
|
__imp__sub_822234F0(ctx, base);
|
|
}
|
|
|
|
extern "C" PPC_FUNC(__imp__sub_82444520);
|
|
PPC_FUNC(sub_82444520)
|
|
{
|
|
if (Skip(kSkipEnv))
|
|
return;
|
|
__imp__sub_82444520(ctx, base);
|
|
}
|
|
|
|
// M3: each picture's own command-processor time and draws (command processor
|
|
// thread, at each swap: the work since the last swap is this picture's), in-between and real
|
|
// pictures apart, a line every 1200 pictures.
|
|
void hfr::NotePictureCost(bool between, double cpMs, uint64_t draws, uint64_t passes)
|
|
{
|
|
if (!s_enabled)
|
|
return;
|
|
struct Sum
|
|
{
|
|
uint64_t n = 0, draws = 0, passes = 0;
|
|
double cp = 0, cpMax = 0;
|
|
};
|
|
static Sum sums[2];
|
|
static uint64_t pictures = 0;
|
|
// The in-between picture's share of a real one's cost (the load check's estimate).
|
|
static double emaBetween = 0, emaReal = 0, lastBetween = -1;
|
|
if (between)
|
|
lastBetween = cpMs;
|
|
else if (lastBetween >= 0)
|
|
{
|
|
emaBetween = emaBetween ? emaBetween * 0.98 + lastBetween * 0.02 : lastBetween;
|
|
emaReal = emaReal ? emaReal * 0.98 + cpMs * 0.02 : cpMs;
|
|
lastBetween = -1;
|
|
if (emaReal > 0.5)
|
|
s_betweenShare.store(float(std::clamp(emaBetween / emaReal, 0.25, 1.25)), std::memory_order_relaxed);
|
|
}
|
|
Sum& s = sums[between ? 1 : 0];
|
|
s.n++;
|
|
s.draws += draws;
|
|
s.passes += passes;
|
|
s.cp += cpMs;
|
|
s.cpMax = std::max(s.cpMax, cpMs);
|
|
if (++pictures % 1200)
|
|
return;
|
|
auto avg = [](const Sum& x, double v) { return x.n ? v / double(x.n) : 0.0; };
|
|
fprintf(stderr, "[hfr] command processor per picture: in-between %llu, %.2f ms (max %.2f), %.0f draws, %.1f passes; real %llu, "
|
|
"%.2f ms (max %.2f), %.0f draws, %.1f passes; passes left out: %llu shadow, %llu view 4, %llu environment, %llu luminance; %llu real pictures took the last luminance reading\n",
|
|
(unsigned long long)sums[1].n, avg(sums[1], sums[1].cp), sums[1].cpMax, avg(sums[1], double(sums[1].draws)),
|
|
avg(sums[1], double(sums[1].passes)), (unsigned long long)sums[0].n, avg(sums[0], sums[0].cp), sums[0].cpMax,
|
|
avg(sums[0], double(sums[0].draws)), avg(sums[0], double(sums[0].passes)), (unsigned long long)s_skipped[0].exchange(0),
|
|
(unsigned long long)s_skipped[1].exchange(0), (unsigned long long)s_skipped[2].exchange(0), (unsigned long long)s_skipped[3].exchange(0),
|
|
(unsigned long long)s_staleLuminance.exchange(0));
|
|
sums[0] = sums[1] = {};
|
|
}
|