| 1 | package com.rox.save; | |
| 2 | ||
| 3 | import com.rox.cartridge.Cartridge; | |
| 4 | ||
| 5 | import java.io.IOException; | |
| 6 | import java.nio.file.Path; | |
| 7 | import java.time.Duration; | |
| 8 | import java.time.Instant; | |
| 9 | import java.util.Optional; | |
| 10 | import java.util.logging.Level; | |
| 11 | import java.util.logging.Logger; | |
| 12 | ||
| 13 | /** | |
| 14 | * Mirrors real battery-backed SRAM: write-through, no timer, no user action - the disk file is | |
| 15 | * updated after every write to {@code $6000-$7FFF} ({@link Cartridge#setOnPrgRamWrite}), same as a | |
| 16 | * real battery-backed chip never losing a write. The actual disk write always happens on this | |
| 17 | * class's own background thread, never the emulation thread {@link Cartridge#write} runs on, | |
| 18 | * matching {@code SpeakerAudioOutput}'s own never-block-the-emulation-thread discipline - writes | |
| 19 | * arriving faster than disk I/O naturally coalesce onto the flush thread's next loop iteration | |
| 20 | * rather than blocking or queuing unboundedly. | |
| 21 | */ | |
| 22 | public final class BatterySaveManager { | |
| 23 | private static final Logger log = Logger.getLogger(BatterySaveManager.class.getName()); | |
| 24 | ||
| 25 | private final Path saveFile; | |
| 26 | private final Cartridge cartridge; | |
| 27 | private final Object lock = new Object(); | |
| 28 | private final Instant createdAt; | |
| 29 | private final long baseGameTimeMillis; | |
| 30 | private final Instant sessionStart = Instant.now(); | |
| 31 | private volatile boolean running; | |
| 32 | private boolean pendingWrite; | |
| 33 | //true whenever prgRam has changes not yet successfully written to disk - deliberately separate | |
| 34 | //from pendingWrite (the wake signal): pendingWrite is always cleared before attempting a flush, so | |
| 35 | //a *failed* flush leaving it set would busy-loop retrying instead of going back to sleep. dirty is | |
| 36 | //only cleared once a flush genuinely succeeds, and is only re-attempted when either a later write | |
| 37 | //wakes the loop again, or once, right before shutdown, if nothing ever did | |
| 38 | private boolean dirty; | |
| 39 | private Thread flushThread; | |
| 40 | ||
| 41 | private BatterySaveManager(final Path saveFile, final Cartridge cartridge, final Instant createdAt, final long baseGameTimeMillis){ | |
| 42 | this.saveFile = saveFile; | |
| 43 | this.cartridge = cartridge; | |
| 44 | this.createdAt = createdAt; | |
| 45 | this.baseGameTimeMillis = baseGameTimeMillis; | |
| 46 | } | |
| 47 | ||
| 48 | /** | |
| 49 | * If a battery save already exists for this cartridge, loads it straight into PRG-RAM - mirrors | |
| 50 | * inserting a cartridge whose battery was already charged. No-op if none exists yet. | |
| 51 | */ | |
| 52 | public static void loadIfPresent(final Path saveFile, final Cartridge cartridge){ | |
| 53 |
2
1. lambda$loadIfPresent$0 : removed call to com/rox/cartridge/Cartridge::restorePrgRam → KILLED 2. loadIfPresent : removed call to java/util/Optional::ifPresent → KILLED |
SaveFileFormat.readBatterySave(saveFile).ifPresent(save -> cartridge.restorePrgRam(save.prgRam())); |
| 54 | } | |
| 55 | ||
| 56 | /** Starts write-through persistence for {@code cartridge} to {@code saveFile}, reusing an existing save's creation timestamp/accumulated game time if one is already on disk. */ | |
| 57 | public static BatterySaveManager start(final Path saveFile, final Cartridge cartridge){ | |
| 58 | final Optional<BatterySaveFile> existing = SaveFileFormat.readBatterySave(saveFile); | |
| 59 |
1
1. lambda$start$0 : replaced return value with null for com/rox/save/BatterySaveManager::lambda$start$0 → KILLED |
final Instant createdAt = existing.map(save -> save.metadata().createdAt()).orElseGet(Instant::now); |
| 60 |
1
1. lambda$start$1 : replaced Long return value with 0L for com/rox/save/BatterySaveManager::lambda$start$1 → KILLED |
final long baseGameTimeMillis = existing.map(save -> save.metadata().accumulatedGameTimeMillis()).orElse(0L); |
| 61 | ||
| 62 | final BatterySaveManager manager = new BatterySaveManager(saveFile, cartridge, createdAt, baseGameTimeMillis); | |
| 63 | manager.running = true; | |
| 64 |
1
1. start : removed call to com/rox/cartridge/Cartridge::setOnPrgRamWrite → KILLED |
cartridge.setOnPrgRamWrite(manager::onWrite); |
| 65 | manager.flushThread = new Thread(manager::run, "BatterySaveManager-flush"); | |
| 66 | //XXX a new Thread already inherits isDaemon() from its creator - a test asserting isDaemon() | |
| 67 | //true can only distinguish this call from a genuinely non-daemon *caller*, which a mutation- | |
| 68 | //testing harness's own test-execution thread isn't guaranteed to be; accepted, environment- | |
| 69 | //dependent gap, not a real behavioral difference `./gradlew test` itself would ever exercise | |
| 70 |
1
1. start : removed call to java/lang/Thread::setDaemon → SURVIVED |
manager.flushThread.setDaemon(true); |
| 71 |
1
1. start : removed call to java/lang/Thread::start → KILLED |
manager.flushThread.start(); |
| 72 |
1
1. start : replaced return value with null for com/rox/save/BatterySaveManager::start → KILLED |
return manager; |
| 73 | } | |
| 74 | ||
| 75 | private void onWrite(){ | |
| 76 | synchronized (lock){ | |
| 77 | pendingWrite = true; | |
| 78 | dirty = true; | |
| 79 |
1
1. onWrite : removed call to java/lang/Object::notifyAll → KILLED |
lock.notifyAll(); |
| 80 | } | |
| 81 | } | |
| 82 | ||
| 83 | private void run(){ | |
| 84 | while (true){ | |
| 85 | synchronized (lock){ | |
| 86 |
2
1. run : negated conditional → TIMED_OUT 2. run : negated conditional → KILLED |
while (running && !pendingWrite){ |
| 87 | try { | |
| 88 |
1
1. run : removed call to java/lang/Object::wait → TIMED_OUT |
lock.wait(); |
| 89 | } catch (InterruptedException e){ | |
| 90 | //this thread isn't exposed for a test to interrupt directly, unlike stop()'s | |
| 91 | //own join() below (interruptible via the *calling* thread) - accepted gap, same | |
| 92 | //category as NES.powerOn()'s own defensive interrupt-restore branches | |
| 93 |
1
1. run : removed call to java/lang/Thread::interrupt → NO_COVERAGE |
Thread.currentThread().interrupt(); |
| 94 | } | |
| 95 | } | |
| 96 |
2
1. run : negated conditional → TIMED_OUT 2. run : negated conditional → TIMED_OUT |
if (!running && !pendingWrite){ |
| 97 |
1
1. run : negated conditional → KILLED |
if (dirty){ |
| 98 | //shutting down with an earlier flush failure that nothing ever retried - one | |
| 99 | //last attempt, not an unbounded retry loop | |
| 100 |
1
1. run : removed call to com/rox/save/BatterySaveManager::flush → KILLED |
flush(); |
| 101 | } | |
| 102 | return; | |
| 103 | } | |
| 104 | pendingWrite = false; | |
| 105 | } | |
| 106 |
1
1. run : removed call to com/rox/save/BatterySaveManager::flush → KILLED |
flush(); |
| 107 | } | |
| 108 | } | |
| 109 | ||
| 110 | private void flush(){ | |
| 111 | final int[] prgRam = cartridge.prgRam(); | |
| 112 |
1
1. flush : Replaced long addition with subtraction → KILLED |
final long gameTimeMillis = baseGameTimeMillis + Duration.between(sessionStart, Instant.now()).toMillis(); |
| 113 | final SaveMetadata metadata = new SaveMetadata(SaveType.BATTERY_PRG_RAM, createdAt, gameTimeMillis); | |
| 114 | try { | |
| 115 |
1
1. flush : removed call to com/rox/save/SaveFileFormat::writeBatterySave → KILLED |
SaveFileFormat.writeBatterySave(saveFile, metadata, prgRam); |
| 116 | synchronized (lock){ | |
| 117 | dirty = false; | |
| 118 | } | |
| 119 | } catch (IOException e){ | |
| 120 | log.log(Level.WARNING, "Could not write battery save to " + saveFile, e); | |
| 121 | } | |
| 122 | } | |
| 123 | ||
| 124 | /** | |
| 125 | * Stops write-through persistence, flushing one last time first if a write was still pending or an | |
| 126 | * earlier flush had failed. Never returns while that final flush is still in progress - retries the | |
| 127 | * join until the flush thread has genuinely terminated, the same interrupt-retry pattern | |
| 128 | * {@code NES.powerOn()} uses for its own lifecycle-critical thread, rather than a single bounded | |
| 129 | * join that could return early and let JVM shutdown kill a daemon thread mid-write. | |
| 130 | */ | |
| 131 | public void stop(){ | |
| 132 | synchronized (lock){ | |
| 133 | running = false; | |
| 134 |
1
1. stop : removed call to java/lang/Object::notifyAll → TIMED_OUT |
lock.notifyAll(); |
| 135 | } | |
| 136 | boolean interrupted = false; | |
| 137 | //XXX removing the join() call itself is a hard-to-kill mutant: isAlive() alone still converges | |
| 138 | //to the same observable outcome (busy-spinning instead of blocking until the thread is genuinely | |
| 139 | //dead) - accepted, matches NES.powerOn()'s own identically-shaped retry-join loop | |
| 140 |
1
1. stop : negated conditional → KILLED |
while (flushThread.isAlive()){ |
| 141 | try { | |
| 142 |
1
1. stop : removed call to java/lang/Thread::join → SURVIVED |
flushThread.join(); |
| 143 | } catch (InterruptedException e){ | |
| 144 | interrupted = true; | |
| 145 | } | |
| 146 | } | |
| 147 |
1
1. stop : negated conditional → KILLED |
if (interrupted){ |
| 148 |
1
1. stop : removed call to java/lang/Thread::interrupt → KILLED |
Thread.currentThread().interrupt(); |
| 149 | } | |
| 150 | } | |
| 151 | ||
| 152 | /** Test seam: direct access to this instance's own flush thread - avoids a name-based search across every thread in the JVM, fragile when more than one manager (e.g. across different tests) shares the same thread name. */ | |
| 153 | Thread flushThreadForTesting(){ | |
| 154 |
1
1. flushThreadForTesting : replaced return value with null for com/rox/save/BatterySaveManager::flushThreadForTesting → KILLED |
return flushThread; |
| 155 | } | |
| 156 | } | |
Mutations | ||
| 53 |
1.1 2.2 |
|
| 59 |
1.1 |
|
| 60 |
1.1 |
|
| 64 |
1.1 |
|
| 70 |
1.1 |
|
| 71 |
1.1 |
|
| 72 |
1.1 |
|
| 79 |
1.1 |
|
| 86 |
1.1 2.2 |
|
| 88 |
1.1 |
|
| 93 |
1.1 |
|
| 96 |
1.1 2.2 |
|
| 97 |
1.1 |
|
| 100 |
1.1 |
|
| 106 |
1.1 |
|
| 112 |
1.1 |
|
| 115 |
1.1 |
|
| 134 |
1.1 |
|
| 140 |
1.1 |
|
| 142 |
1.1 |
|
| 147 |
1.1 |
|
| 148 |
1.1 |
|
| 154 |
1.1 |