Repository navigation
Conversation
-headless runs the game with a hidden window, no drawing and no audio, with the sim running as fast as it can instead of in real time. -save <name> and -activity <type> <preset> <scene> skip the menus and launch straight into a saved game or an Activity, and -sim-updates <count> quits after that many sim updates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sim makes checkpoints as it goes, each a label and a hash of some of its state. -determinism-record <file> writes these to a trace file, and -determinism-verify <file> checks a later run against that trace, reporting the first checkpoint that doesn't match and exiting with a failure code. Nothing gets hashed unless one of the two is given. DeterminismCheckSimState checkpoints the RNGs, all MovableObjects, the Activity, and every so often the terrain and unseen layers. It's done at a few points through MovableMan::Update, to narrow down which part of the update a divergence comes from. DeterminismCheck checkpoints any values passed to it, for narrowing things down further. While checking, the number of Lua states is fixed if there's no override set, since scripted objects get spread out over them, so how many there are can't be left up to the machine. Runs only match with ForceImmediatePathingRequestCompletion turned on in the settings. Pathing requests otherwise complete after however many sim updates the pathfinding threads happen to take. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Runs each scenario headless to record a determinism trace, then verifies further runs against it, optionally including a windowed one. The runs use a copy of the game's settings that has ForceImmediatePathingRequestCompletion turned on, so the game's own settings don't have to be changed for them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There's no one around to dismiss a message box when headless, so aborts and failed asserts now just log and exit, and other messages go to the CLI. Headless runs also leave the abort save alone, since they're automated and shouldn't replace one from an actual play session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Each Lua state kept the MOs registered with it in an unordered_set of pointers, so the order their ThreadedUpdate and SyncedUpdate functions were run in followed their addresses in memory, which are different every time the game is run. The same MOs would be registered in two runs of the same scenario, but run in a different order, which made the runs diverge. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
LuaJIT's own PRNG decides the IDs that strings get hashed by, which decides the order pairs() goes through a table with string keys in. With the PRNG seeded from the OS, that order would occasionally come out different from one run of the game to the next. Decision Day spawns its initial defenders while going through its bunker regions with pairs(). Roughly 1 in 15 runs had the same Actors end up in different regions from the rest, with everything else being equal. With a fixed seed, 60 out of 60 runs started out the same. This doesn't make the order of tables with objects as keys predictable, since those are hashed by their address. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The AI scripts of Actors get run on several threads at once, one per Lua state, and some of what they call draws from the global random number generator, like AHuman::LookForMOs and ACrab::LookForMOs. That made the global generator end up in a different state from one run to the next, depending on how the threads happened to interleave. Each Lua state now has a second generator that is used while it's running AI, by the Lua state itself and by the global random functions called from the thread it's being run on. So the AI no longer touches the random numbers that the rest of the sim gets, whatever it does and whichever thread it's on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Unique IDs came from a single counter shared by all threads. The threaded Lua states make MOs at the same time as each other, so which of those MOs got which unique ID was down to how the threads happened to interleave. A thread would hand out the same number of unique IDs from one run to the next, but not the same ones. The upper bits of a unique ID are now the number of the thread it was handed out on, 0 for the main thread and counting on from there for the threaded Lua states, and the rest count up separately for each thread. Unique IDs are 64 bit now to have room for that. Scripts get them as Lua numbers though, which hold whole numbers exactly up to 53 bits, but only come out in full when turned into text, like with tostring(), if they are 14 digits or less. So unique IDs are kept within 46 bits, 7 for the thread number and 39 for counting, which is about 550 billion per thread. luabind didn't have converters for long long, which is what int64_t is on Windows, so those are added. The number of threaded Lua states is capped at 127, since that is how many thread numbers there are for them. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SaveNumber and LoadNumber kept their numbers as floats, which can't hold whole numbers above about 16.7 million exactly. Unique IDs can be far larger than that now, and are an obvious thing for a script to want to save. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Floats and doubles were written with whatever precision the stream happened to be set to. For files that was 6 decimals, and for the in-memory stream that saved games are written to it was the default of 6 significant digits. So a position of 12345.678 was saved as 12345.7, and a number value of 1234567, like a unique ID kept with SetNumberValue, as 1.23457e+06. They are now written with as many digits as it takes to read back exactly the same number, and no more, so 0.5 instead of 0.500000 and 1 instead of 1.000000. Loading a saved game gives back the numbers that were saved. One visible effect is on Settings.ini files made from now on. The default DeltaTime of 0.0166666 used to get written as 0.016667, and now stays as it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It was left out of AudioMan::Clear, so a newly made Settings.ini got whatever happened to be in memory written to it for the setting, like 44. That isn't something a bool can be read from, so the next time the game was started, reading the settings would fail at that line, and none of the settings after it would get applied. It now starts out as off, which is how the game behaved before there was a setting for it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With it on, the game doesn't show a dialog when it aborts, runs into an unhandled exception or fails an assert. It logs what happened like it otherwise would, and then quits, with a failed assert counting as an abort since there's no asking whether to ignore it. Headless mode already worked this way. This is for when the game is being run with its window by something automated, where a dialog would leave it waiting on someone to come and dismiss it. The determinism test script turns it on for its runs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
How many times things get drawn per sim update is up to the frame rate, so drawing code that used the sim's random numbers left the sim with different ones from one run to the next. With the aim dots in HDFirearm::DrawHUD and the screen shake in CameraMan::Update, which FrameMan::Draw calls, runs that only differed in how many times they drew per sim update diverged within the first couple of updates. Audio isn't up to the sim either. Whether there is any depends on the machine, and when the music moves on depends on real time. All of it now has to use g_DrawRandomGenerator: the aim dots, the screen shake, the throwing reticle, the glow dots in post processing, randomly animated backgrounds, the picking of sounds and music and their pitch variation, the seed given to FMOD, and the stars and flickering in the title screen, scenario menu and metagame menu. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e ID Scripts on different Lua states add MOs at the same time in their threaded updates, and which thread got its MOs into the list first was different from run to run. Two incendiary bullets on different Lua states each making a flame particle in the same update was enough: the same two particles ended up in either order, and the runs diverged from there. The MOs waiting to be added now get sorted by unique ID after the threaded script update and the threaded AI update. Unique IDs already say which thread an MO was made on and how many that thread had made before it, so that order is the same every time. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Scene::Update gets called once per sim update, but whether it then went on to update the pathfinders was up to a real time timer, so which sim update that happened in depended on how fast the game was running. This is the same change as on the interpolated-render branch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…dress A table with tables, functions, userdata or threads for keys got iterated in order of where those happened to be in memory, which is different every run. The automover controller builds the scene's no gravity area ten nodes at a time by going through such a table, so early on that area differed from run to run, and with it the paths the pathfinder came up with for the AI. Each of these objects now gets an ID when it's made, from a count that its Lua state keeps, and that is what gets hashed. The order then only depends on what has been run in that Lua state. With 64 bit GC references all four have four bytes of padding at the offset where a string has its own ID, so nothing grows, and the JIT backends can load the ID the same way they already do for strings. Only the x64 and arm64 backends have been changed and it's only turned on for those. The arm64 one hasn't been run. cdata and light userdata are still hashed by address. cdata gets allocated by code that the backends emit, and a light userdata is an address to begin with. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
It returns true right away when the pixel it's asked about is air, and Atom::Travel and AtomGroup then go on to use the retardation it was supposed to give them, which nothing had set. The object's velocity got changed by whatever happened to be in that memory, which was different from run to run. The terrain can be gone by then because a script's OnCollideWithTerrain gets run between the hit being detected and the penetration being tried. This was seen with Ground Flames hitting grass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| // The main thread is number 0, and the thread that is running a threaded Lua state has the number after that state's. | ||
| int64_t threadNumber = 0; | ||
| if (const LuaStateWrapper* luaState = g_LuaMan.GetThreadLuaStateOverride(); luaState && luaState != &g_LuaMan.GetMasterScriptState()) { | ||
| threadNumber = 1 + (luaState - g_LuaMan.GetThreadedScriptStates().data()); |
There was a problem hiding this comment.
Might ask for this to be adjusted, using .data() here as ptr arithmatic feels messy, but it's not an issue in itself.
| if (IsSetToLaunchIntoEditor()) { | ||
| // Evaluate LaunchIntoEditor before LaunchIntoActivity so it takes priority when both are set, otherwise it is ignored and editor is never launched. | ||
| return SetStartEditorActivitySetToLaunchInto(); | ||
| } else if (!m_SaveGameToLaunch.empty()) { |
There was a problem hiding this comment.
This, and the other changes in this file, are just to support headless testing of activities and savegames
| Destroy(); | ||
| } | ||
|
|
||
| bool LuaStateWrapper::UniqueIDOrder::operator()(const MovableObject* lhs, const MovableObject* rhs) const { |
There was a problem hiding this comment.
Used to ensure that scripts are run in a deterministic order.
| /// Gets a list of the MOs registed as using us. | ||
| /// @return The MOs registed as using us. | ||
| const std::unordered_set<MovableObject*>& GetRegisteredMOs() const { return m_RegisteredMOs; } | ||
| const std::set<MovableObject*, UniqueIDOrder>& GetRegisteredMOs() const { return m_RegisteredMOs; } |
There was a problem hiding this comment.
No a huge fan of using a set here at the std impl is awful, but wasn't something I felt a need to change for now.
| } | ||
|
|
||
| g_LuaMan.SetThreadLuaStateOverride(&g_LuaMan.GetMasterScriptState()); | ||
| g_LuaMan.GetMasterScriptState().SetRunningAI(true); |
There was a problem hiding this comment.
When running AI, we use a different PRNG. This is because the long-term plans are to have AI not deterministic at all, and instead have the controller states synced. This means we cannot have AI usages of random values pollute the sim randoms.
| } | ||
|
|
||
| int SettingsMan::GetNumberOfLuaStatesOverride() const { | ||
| constexpr int luaStatesWhenCheckingDeterminism = 4; |
There was a problem hiding this comment.
I'll probably ask for this to be changed into purely using the Settings.ini value. There are a few other cases where values like this were hardcoded that I asked to be removed, but this is okay for now. We'll probably settle on a fixed (but relatively high) number of lua states for determinism, as it's not a huge amount of extra cost even though machines with less cores will need to waste some time context switching.
| writer.NewLine(false); | ||
| writer.NewPropertyWithValue("DisableLuaJIT", m_DisableLuaJIT); | ||
| writer.NewPropertyWithValue("EnableLuaDebugging", m_EnableLuaDebugging); | ||
| writer.NewPropertyWithValue("DisableCrashDialogs", RTEError::s_DisableCrashDialogs); |
There was a problem hiding this comment.
Used so that headless runs can just print an error to abort log instead of locking up the tester.
| } else if (m_IntroSequenceState == IntroSequence::SlideshowEnd) { | ||
| m_PreGameLogoText.Draw(g_FrameMan.GetBackBuffer32()); | ||
| int blendAmount = 220 + RandomNum(-35, 35); | ||
| int blendAmount = 220 + g_DrawRandomGenerator.RandomNum(-35, 35); |
There was a problem hiding this comment.
These changes aren't strictly necessary here, but I asked for them to be done. In future we'll serialize the PRNG seed whenever starting a saved activity to ensure that prior random calls from past activities don't pollute anything.
| } | ||
|
|
||
| bool RTEError::ShowAbortMessageBox(const std::string& message) { | ||
| if (System::IsInHeadlessMode() || s_DisableCrashDialogs) { |
There was a problem hiding this comment.
Not sure it makes sense to have these headless checks here when the proper Settings.ini setup will do, but again, eh
| public: | ||
| static bool s_CurrentlyAborting; //!< Flag to prevent a potential recursive fault while attempting to save the game when aborting. | ||
| static bool s_IgnoreAllAsserts; //!< Whether to skip the assert dialog and just let everything burn at whatever point that happens. | ||
| static bool s_DisableCrashDialogs; //!< Whether to not show a dialog when the game aborts, runs into an unhandled exception or fails an assert, and just log it and quit. This is the DisableCrashDialogs setting. |
There was a problem hiding this comment.
"This is the DisableCrashDialogs setting." - hah. I'll get this pointless note removed.
| } | ||
|
|
||
| auto ofStream = std::make_unique<std::ofstream>(fileName, append ? (std::ios::out | std::ios::app | std::ios::ate) : (std::ios::out | std::ios::trunc)); | ||
| *ofStream << std::fixed << std::setprecision(6); |
There was a problem hiding this comment.
We're not using a fixed precision of 6 anymore, now we save the full absolute value to whatever precision is required to store it accurately. The fact that saves are zipped now makes this extra space usage less significant.
It said that this is the DisableCrashDialogs setting, which is obvious from context. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CHANGELOG.md and RTEError.cpp conflicted. The message box functions check for headless mode and DisableCrashDialogs first, as on this branch, and then for being off the main thread, as on development. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Seeding LuaJIT's PRNG with a fixed value wasn't enough to keep pairs() going through tables with string keys in the same order from run to run. LuaJIT also draws from that PRNG to pick the addresses it tries when it needs memory for compiled traces, and draws again for each one that is already taken. How often that happens depends on how the process' memory happens to be laid out, so the IDs that later strings got reseeded with still differed between runs. DeliveryCreationHandler picks the type of each infantry unit by going through a table of them with pairs(). In the AI Battle on Alezer Canyon Bunker, two runs with the same random number for the pick (35 of 48) made a sniper in one and an engineer in the other, and so the sim started out in one of two ways. With the IDs given out in order, 16 of 16 runs started out the same. The order still follows from which strings a Lua state has made so far, and tables with objects as keys still go by their addresses. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An AHuman remembers the item it found within reach as a plain pointer. The check for whether that item was still around looked at the item itself before asking MovableMan if it still existed, so an item that got deleted in the meantime, such as a grenade that went off, was read from memory that had been freed. The HUD, the pie menu and Lua's ItemInReach used the pointer with no check at all. What that read comes back with isn't the same from run to run, and it decides whether the item stays in reach, which in turn decides how many random numbers the next reach check uses. AddressSanitizer reported it in the AI Battle on Eleann Forest, and no longer does. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… request The count is what gets waited on for the async pathing requests to be done, but a request was only in it while its path was being calculated. One that was still queued up for a thread wasn't counted yet, and one that was done calculating stopped being counted before it was marked as complete. Either way a wait could end with requests still left, and an Actor would get its path a sim update later than it would have. The counting is now done where the request is made and where it is marked as complete, instead of in CalculatePath. Paths that get calculated right away for their caller aren't counted anymore, as the count was only ever meant for the async ones. A wait ending early wasn't seen to happen in testing, there was just nothing to keep it from happening. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… immediately The Scene updates its PathFinders with the changes to the terrain every 100 ms of sim time, but puts it off for as long as any pathing request is active. Which sim update the pathfinding threads were done by, and so which one the update happened on, differed from run to run, and paths requested around then came out different. In the AI Battle on Eleann Forest that showed up as a just landed Actor starting off with a jump in some runs and by walking in others, in 4 of 26 runs. With ForceImmediatePathingRequestCompletion on, the update now waits for the requests instead, and 52 of 52 runs came out the same. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ShootArea timed its shots, and how fast its aim settled, by real time, unlike the rest of the AI. How much real time a sim update takes isn't the same from run to run, least of all with the sim going as fast as it can, so Actors shooting at an area aimed and fired slightly differently each time. In the AI Battle on Eleann Forest, 1 of 6 runs had an Actor's aim differ by 0.0008 radians from the others, with all else being equal up to then. That didn't happen again in 72 runs after this. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With --jobs the scenarios get run that many at a time, each still doing its own recording run followed by its verifying runs. The game can write to its settings file, so each scenario now gets a copy of its own instead of all of them sharing one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
-seed <number> adds the number to the seed of the sim's random number generator, so each number has the same scenario play out differently. With 0 the sim gets its usual random numbers. The generators are now first seeded after the early arguments are handled instead of before, so that the Lua states, which take their seeds from the sim's generator, get different ones too. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…is over An unattended run could so far only be ended after a set number of sim updates. With this it can instead go for as long as the Activity takes to finish, which is what a determinism test of a whole battle needs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…he Activity is over With --seeds each scenario gets run once for every seed given, each of which has it play out differently, to cover more of what can happen in it. With --until-over the runs go on for as long as the Activity takes to finish instead of for a set number of sim updates, and giving --updates along with it stops a run that hasn't got there by then. These go with the -seed and -quit-when-over launch arguments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Headless mode left the audio system uninitialised, so whatever in the sim goes by audio came out differently than in a normal run. A sound never counted as being played, for example, which scripts wait on. FMOD now gets initialised when headless as well, with its no-sound output, which does everything it otherwise would except play the result. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The dotted lines that get drawn at the exits of a craft with its hatch open were laid out from the velocity that things leave through the exit at. That has a spread to it, which is rolled with the simulation's random numbers every time it is asked for. How the simulation went on from there depended on how many times the HUD had been drawn, so a windowed run stopped matching a headless one, where nothing gets drawn, as soon as a hatch opened. The lines only need the way the exit faces, so they go by the velocity without its spread now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AboveHUDPos of an Actor comes from its HUD stack, which only drawing the HUD sets. It depends on how many lines the HUD had on the screen it was last drawn to, and is left at its starting value when nothing is drawn. The crosses of the self heal, medic drone, medikit and medical dart scripts were placed by it. They are particles in the simulation, and collide with the terrain. The AA drone cast the rays that decide whether it fires from it. So a headless run and one with a window played out differently. These now go by the position and height of the Actor instead, which gives what AboveHUDPos does for an Actor whose HUD shows the health and nothing more. Everything else that uses AboveHUDPos does so for drawing, and is left as it was. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… the simulation Whether a SoundContainer is being played is up to the audio system, which plays in real time. What a script does on the back of it happens at a different point of the simulation every time, and all the more so in headless mode, where the simulation doesn't go by real time at all. The Gatling Gun, Bulldog and Giga Pulsar started spinning down when they were let go of while their ActiveSound was still playing. They now keep track by themselves of having been activated. The refinery drill blew up when its overload sound was done playing, and the Gunsword didn't swing again until its swing sound was. Both now wait for as much sim time as those sounds are long. Scripts that only play, move or stop sounds depending on it are left as they were, since nothing in the simulation comes of that. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The sound only got played when it was already being played, so it never started. The pitch it was to be played at was also rolled with a call that would have failed, had it ever been made. It is now played when it isn't already playing, and gets its pitch varied by the SoundContainer itself. A roll made in the script would use the simulation's random numbers, and that can't depend on whether the audio system is still playing the sound. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
An Atom's trail only gets drawn in the sim updates that end up on screen, and how much of it gets drawn was rolled with the simulation's random numbers. With more than one sim update to a frame, as when the time scale is turned up or the frame rate drops below the simulation's, the updates in between rolled nothing. How the simulation went on from there depended on how many frames had been drawn, so a run at double speed stopped matching one at normal speed within a few updates. The trail length is rolled with the generator for drawing now. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… its threaded update The threaded updates of the lua states' scripts are run at the same time, on a thread each. Whatever they called in the engine that uses the global random functions got its random numbers from the simulation's one generator, from all of those threads at once. Each lua state now has its own generator used on its thread for as long as its threaded update is being run, in the same way as is done for AI. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This is a hand-reviewed PR created from the output of Claude over the past day. I've looked over the code already and confirmed it makes sense, of course with a fair bit of prodding towards the correct decisions.
Main architectural things to mention:
It's worth noting that Claude initially suggested changing many different parts of the codebase based on reading the code and effectively gut-feeling. I declined these changes as I knew some of these suggestions were not accurate, and so each individual change/fix here has been tested to ensure that they actually fix a real determinism issue.
This is only part one- further work will need to be done in order to handle user inputs and more complex scenarios, as well as tracking controller and user input for when we move AI state syncing to pure controller state syncing, but this is already pretty accurate (at least when run in a headless mode). Most scenarios in vanilla of course just go into the pre-game editor phase, but determinism in a sim without user inputs has been confirmed in Dummy Assault and Decision Day too which are a bit more complicated, and I was sure to get those simulated for 100,000 game ticks each, which is about half an hour of game-time.
Makes the simulation deterministic enough that two headless runs of an activity play out identically, and adds the tooling to check that.
TLDR: This is deterministic assuming a headless run, with a fixed number of Lua states, and with ForceImmediatePathingRequestCompletion = 1. Further steps will be handling determinism when some of these constraints don't hold. Claude notes that the async pathing may still be a concern below, but we're gonna handle that by just not running the AI on each machine independently.
Claude PR notes below here:
What's in it
Tooling
-headlessruns the game with a hidden window, no drawing and no audio, with the sim going as fast as it can.-activity <class> <preset> <scene>,-save <name>and-sim-updates <count>launch straight into something and quit after a set number of updates.DeterminismChecker:-determinism-record <file>writes checkpoints (a label plus a hash of sim state) to a trace as the sim runs, and-determinism-verify <file>checks a run against a trace, reports the first checkpoint that differs and exits with a failure code. Nothing is hashed unless one of the two is given.Tools/determinism_test.pyrecords each mission once and verifies further runs against it. It uses its own copy ofSettings.iniwithForceImmediatePathingRequestCompletionand the newDisableCrashDialogsturned on.Fixes, each for a divergence that was seen in a run first
pairs()over string keys. It's now built with a fixed seed.pairs()over a table keyed by objects went in a different order every run. They are now hashed by an ID kept in the object (x64 and arm64 only).SceneMan::TryPenetratereturned true without setting its retardation when the pixel was already air, andAtom::TravelandAtomGroupwent on to use it. It's now set to zero there.Smaller things that came up along the way
Activitysave and load numbers are doubles, and theWriterwrites floating point numbers out exactly.MuteAudioOnFocusLosswasn't initialized.Testing
Debug Release build, Windows x64, headless, on the last commit. Each mission was recorded once and two more runs were verified against the recording.
Brain vs Brain was also run, but it ends after one update when launched this way, so it tested nothing.
Not covered
ForceImmediatePathingRequestCompletionmakes the results arrive by the next update, but not at a fixed point within it.🤖 Generated with Claude Code