Repository navigation
Headless replay player - #1950
MichalLabuda wants to merge 15 commits into
Conversation
|
Replay-Player is very similar to AI-Battle and theoretically could be implemented as an additional mode to it instead of completely separate app. I've decided to implement it like this but I'm not entirely convinced that this is the way to go. |
Flamefire
left a comment
There was a problem hiding this comment.
Looks good but I'm wondering if we can unify loading the replay. Especially with the fish fix it becomes a maintenance burden. Can you investigate this?
And it doesn't make sense to count asyncs: Every NWF after an async will be async too. So you can stop right there. Or at least report it only once if you see any use for letting it continue.
|
Great idea having a headless replay! I have been wanting this feature myself. My suggestion would be to make HeadlessReplay a class instead of just a collection of types and helper functions. The binary in this PR checks replays. I would like to be able to create another binary, that uses the same class and extracts training data from replays. How about something like this: class HeadlessReplay
{
public:
HeadlessReplay(const boost::filesystem::path& replay);
bool Run();
}; |
Every frame after the first desync desyncs too, so the count told nothing beyond that the replay went out of sync. ReplayInfo now keeps the GF the first desync was detected at, and the notice at the end of the replay names it.
ai-battle, replay-player and the autoplay test each defined the same ILocalGameState that acts as the host playing as player 0 and discards all output.
Moves the savegame/map branch of GameClient::StartGame into a function the headless tools can share: snapshot or map loading, Lua, start pacts, fish fix and replay compat version, plus the fixup of the leather addon distribution for old replays from StartReplay. When the MapInfo has no file path, the map and Lua script are taken from the data embedded in it. Failures are reported as GameSetupError. Add Replay::usesFishFix() so the version rule lives in one place.
printConsole, the clock and number formatting and the status table move into a library the replay-player can use too. The table takes the replay-player's layout, which can also show the total GF count.
The replay-player had its own copy of the replay loading and playback loop. HeadlessReplay loads a replay through SetupGameWorld, as GameClient does, and plays it back frame by frame with RunGF() or entirely with Run(callback). It stops at the first desync before running that frame, so the world and the async log of the RNG still show the state that produced it. The replay-player is built on it and stops at the first desync instead of counting them. It prints through headlessConsole, the rest of its output moves to ReplayOutput.
The test had its own copy of the playback loop, which also required the replay started from a map to predate the fish fix. A failure now names the GF of the desync.
Replaces the hand-rolled map, Lua and start-pact setup with the loader GameClient uses. The AI players are now created after InitAfterLoad, as GameClient does. They used to receive the BQ changes it makes through NodeNote::BQ; now an AIJH reads the finished BQs when it is created. The game runs identically: the replays and savegames recorded before and after the change differ only in the version and timestamp header.
They were created as PlayerState::Occupied, the state of a human player, so code that checks for a human treated the AIs as humans. A Lua script could not give them construction orders (AIConstructionOrder returned false), IsHuman() in Lua was true for them, pact requests reached them as post messages and the AUTOFLAGS addon placed flags on their roads. Replays recorded by ai-battle now also store the AI info of the players.
A replay with a Lua script that logs aborted with "Could not open logs/... for writing" unless the working directory happened to contain a logs folder.
Playback ended after the last recorded command, so the final state was a few frames short of the recorded game and a replay without commands played nothing. Like GameClient, the frame with the last GF is executed and the game stops after it. The GF counts shown by the replay-player count frames accordingly.
A command for a GF that already passed can never be executed, so playback silently ran on to the replay's last GF with the remaining commands ignored. RunGF now throws in that case, in Debug and Release alike. Tested with a recorded replay whose second command claims an earlier GF than the first.
|
@Flamefire @wichern Thanks for the reviews, both are addressed now:
I've rewritten the PR description to cover the whole PR in its current state. The new work comes as 11 commits after the master merge, and each builds on its own, so they can be reviewed one by one. This one turned out to be a big job. I did it with the help of Claude Opus 5.5 and Fable 5.1. |
Summary
Adds
replay-player, a headless tool that plays back a.rplreplay without a GUI, shows liveprogress and checks the replay for desyncs.
The playback is a reusable
HeadlessReplayclass ins25Main, so other tools can build on it.Replays and maps are loaded through one code path,
SetupGameWorld, which the game client,HeadlessReplayand ai-battle share.replay-player
player roster, then a status table that updates every second: GF / total GFs, game clock,
wall clock, GF/s, and Country / Buildings / Military / Gold per player.
desync.
and both checksums;
--verbosealso dumps the async log of the RNG.<RTTR_USERDATA>. Lua output goes to the log filein the user data folder instead of stdout.
HeadlessReplay
RunGF()plays back one frame at a time.getGame(),getWorld()andgetReplay()give readaccess to the state, e.g. for extracting training data from replays.
log still show the state that produced it. Every frame after a desync would desync too.
One code path for setting up a game
SetupGameWorld(libs/s25main/GameSetup.h) sets up the world of a new game forGameClient::StartGame,HeadlessReplayand ai-battle. It covers snapshot or map loading, Lua,start pacts, the fish fix, the replay compat version and the leather-distribution fixup for old
replays (moved from
GameClient::StartReplay). Map and Lua script can also be taken from the dataembedded in a replay. The fish-fix version rule lives in
Replay::usesFishFix().Other changes
ReplayInfokeeps the GF of the first desync instead of countingasync frames, and the notice at the end of the replay reports it.
extras/headlessConsole: the console output shared by ai-battle and replay-player(
printConsole, clock and number formatting, the status table).NullLocalGameState(ILocalGameState.h) replaces three identical local copies in ai-battle,replay-player and the autoplay test.
SetupGameWorldinstead of its own map, Lua and start-pact code.PlayerState::AIinstead ofOccupied, which marks a human.Before, Lua's
AIConstructionOrderrefused orders for them (e.g.LuaFunctions.luafailed itsassertion),
IsHuman()was true, pact requests reached them as post messages, and the AUTOFLAGSaddon placed flags on their roads. Replays recorded by ai-battle now also store the AI info.
This dates back to when ai-battle was added.
HeadlessReplayand gains a test case for a replaywith out-of-order commands.
Visible changes
count: %u" to "Notice: The replay was out of sync from GF %u on." The translations will be
updated in a follow-up PR to the languages repository.
Testing
including tests and extras) and passes the clang-format 10 check.
Test_autoplaypasses: the 200k GF replay started from a savegame, the 300k GF sea replaystarted from a map, and the new out-of-order case.
-Werror): the build, alltests,
Test_autoplay, clang-tidy 18 on the changed files, and local runs of the CI's staticanalysis and test coverage checks pass.
LoadBobFile,ReadOriginalFiles) that fail only locally because the S2 game data sits in the build folder.They are unrelated to this PR.
SetupGameWorld: same map, seed and GF count(plain, teams, Lua) gave identical replays and savegames, apart from the version and
timestamp in the header.