Articles

  • As part of the continuing crossed-row work, we looked at reducing how much of the chart the engine scans during gameplay.

    The idea seemed reasonable. If we know the latest row where something changed, the engine should be able to stop its update there instead of continuing farther through the chart.

    StillRiver showed that it was not that simple.

    During testing, meaningful gameplay work was still happening beyond the proposed limit. In some cases, the latest changed row was behind the current tap iterator, the mine iterator, or both.

    Capping the scan at that point would have saved work, but it would also have skipped behaviour the engine was still expected to process.

    Those row paths currently handle more than note positions. They can also advance iterators, apply misses, process mines and hold checkpoints, update combo state, send theme messages and run mode-specific behaviour.

    That does not mean all of that work belongs together forever. It does mean we cannot separate it safely by assuming that rows are always processed as one simple forward range.

    Warps make this especially awkward. A warp can move the chart position forward without normal song time passing through every row. Fake regions and other timing rules can also contain rows that should not be judged in the usual way. Some game modes and converted charts add further exceptions.

    A future row service therefore needs more information than a start row and an end row.

    It needs to know how the engine reached that range, whether the rows are judgeable, which gameplay rules are active, and which remaining effects still need to be processed.

    The first proposed optimisation did not have enough information to guarantee that, so we did not use it.

    That is one of the useful things StillRiver gives us. It does not only show where work can be removed. Sometimes it shows that an apparently obvious shortcut would change gameplay behaviour in ways that are easy to miss.

    The current row path still needs untangling, but we now have better evidence about what it is carrying and where the boundaries cannot yet be drawn safely.

    A faster scan is only an improvement if it produces the same gameplay result.

    This one did not prove that yet.

  • While reviewing the crossed-row gameplay path, we found another place where the engine could use the right information at the wrong moment.

    Some gameplay rules need to know whether a panel was being held when a chart row crossed the receptors. The older path could answer that by checking whether the panel was held when the engine processed the row.

    Most of the time, those moments are close enough to appear identical, however, they are not always identical.

    If a frame arrives late, the engine may need to process several rows together. By then, the player may have pressed or released the panel after one of those rows had already crossed.

    Using the current controller state could therefore apply a later input state to an earlier gameplay decision.

    We have started separating those two moments.

    For normal forward gameplay, OutFox now keeps enough input history to check whether the panel was held at the time the row actually crossed, rather than relying only on its state during the later frame that processed it.

    This is particularly important for held-over-mine behaviour and other crossed-row rules. Those decisions should be based on what the player was doing when the event occurred, not what the controller happens to report several milliseconds later.

    The older behaviour remains available where the newer evidence is not yet safe to use. Warps, seeks, reverse movement, autoplay and some legacy rules still need separate handling, so this is not being applied blindly across every path.

    The change is deliberately focused on ordinary human gameplay moving forwards through a chart.

    If a panel was held when the row crossed, the engine should recognise that, if it was pressed or released afterwards, that later state should not be carried backwards into the judgement.

    It is another small part of moving gameplay decisions away from frame timing and onto the actual song timeline.

  • While reviewing the old crossed-row gameplay logic, we found a mine being judged twice.

    The first hit came from a real input. The player pressed the panel while the mine was in range, and the engine handled it normally.

    Later, when the same row crossed the receptors, another part of the engine checked whether that panel was still being held. It found the same mine and generated a second hit for it.

    Same row, same column and same mine, judged through two different paths.

    Both paths exist for valid reasons. OutFox needs to detect a direct press on a mine, but some game modes also need to punish a player who is already standing on a panel when a mine reaches it.

    The problem was that neither path had final ownership of the decision.

    The direct input route could judge the mine, while the later crossed-row route could still find it and act as though nothing had happened. Each part of the engine was behaving reasonably on its own, but together they allowed the same object to be serviced twice.

    Our gameplay logs showed the sequence clearly. A real mine hit occurred first, followed by a synthetic held-panel hit for the same row and column.

    That gave us a narrow place to fix it.

    The crossed-row mine check now ignores mines that have already been judged or removed from play. Held-over-mine behaviour is unchanged, and the normal direct input path continues to work as before.

    The fix does not alter mine scoring or remove any existing mine rules. It only prevents two parts of the engine from making separate decisions about the same mine.

    This investigation also showed why the older gameplay path needs clearer boundaries.

    Input handling, row crossings, object selection and the resulting effects currently happen across several connected systems. When more than one of those systems believes it owns the same decision, faults like this become possible.

    For now, one mine receives one judgement, which feels like a fairly reasonable rule for a rhythm game.

  • The recent loader work has now reached a point where the difference is difficult to miss.

    One of our older benchmarks recorded 3,902 songs taking 791.658415 seconds to load. That is just over thirteen minutes. When we began the current round of testing, our active 3,722-song library was still taking 677.246704 seconds.

    After working through the loader piece by piece, that same library reached repeated runs of around forty-six seconds. A later validation run completed in 44.012848 seconds. There was no single switch responsible for that change. This was on a USB external 2.5" 5400RPM 2009 laptop drive we had lying around. On newer NVME/SSD architecture, the gains will be much higher.

    A large amount of the loading time came from work being repeated, or from expensive checks being used before the engine knew whether they were necessary.

    The first major improvement came from format detection. Ordinary SSC files were sometimes being checked as possible Pump content based on signals that were too broad. That preserved compatibility, but it also sent thousands of files through parser work they did not need.

    We tightened those checks so Pump content is still recognised where the files provide real evidence, while ordinary SSC charts can continue through the normal path.

    The loader was also asking the filesystem many of the same questions more than once. Once a folder had already been listed, that information could often answer basic media checks without probing the same files again.

    Chart data had a similar problem. Some load-time tasks were repeatedly compressing and rebuilding note data that was already available in memory. Those tasks can now use the existing chart data instead of recreating it during the same load.

    Audio length checks were another large cost. The older tidy-up path could fully open audio files while loading, even when the engine only needed enough information to complete cache data. On a cold startup across several thousand songs, that became extremely expensive.

    The newer loader path uses quicker audio length information where that is sufficient, while leaving full audio opening to the parts of the engine that actually require it.

    Several smaller changes contributed as well. Empty parser routes are skipped, PIU work is avoided when there are no candidates, cache hashing spends less time inside the write lock, and loader state that should belong to one worker is no longer shared across the whole process.

    None of these changes would matter if the faster loader produced a different library.

    Throughout the main validation runs, the song, step, Pump and autogen counts remained stable. The improvement came from removing unnecessary work, not from silently dropping content.

    There is still more loader work to do, but startup for this test library has fallen from more than eleven minutes to under one minute.

    That is a substantial change players with large collections should notice immediately.

  • As part of the loader work, we have also been reviewing how OutFox decides whether cached song data can still be used. Caching is essential for large libraries. Once a song has been parsed, the engine can save the result and avoid repeating the same work every time it starts.

    That only helps when the saved data still matches the files on disk.

    A chart may have been edited, a banner replaced, metadata corrected, or a parser changed since the cache entry was created. If the engine does not recognise that change, it can load stale information even though the source files are correct.

    We have been tightening those checks across song data, edits and media so that OutFox can reuse cached results when they remain valid and rebuild them when they do not.

    One fault uncovered during this work appeared as a difficulty meter of -2147483648.

    That value came from an invalid conversion rather than anything written by the chart author. The obvious fix would have been to clamp unusual meter values globally, but that would also damage legitimate content with intentionally high ratings.

    Instead, we corrected the specific conversion that produced the invalid integer while preserving the original floating-point meter data and authored values.

    Threaded loading exposed another problem.

    Some information that appeared local to one song load was actually being shared between several loads. Image fallback state and parts of the dynamic parser path could therefore produce different results depending on which thread reached them first.

    We have kept the parts of threaded loading that are safe, while restoring ordered processing around the older code that still depends on shared state.

    This is slower than declaring the whole loader parallel and hoping for the best, but it gives us consistent results between runs.

    For players, the cache should simply do its job.

    Unchanged songs should load more quickly. Edited charts and replaced media should be detected. Banners and previews should remain stable, and chart information should not change because the library happened to load in a different order.

    The loader work is not only about making startup faster. It also has to make sure the faster result is the same result we would get from reading the source files again.