Skip to content

Commit d137db4

Browse files
authored
Save and restore state on pause/resume (#85)
1 parent 6cab199 commit d137db4

File tree

17 files changed

+451
-69
lines changed

17 files changed

+451
-69
lines changed

app/build.gradle.kts

+9-1
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ android {
99

1010
defaultConfig {
1111
applicationId = "com.emerjbl.ultra8"
12-
minSdk = 29
12+
minSdk = 26
1313
targetSdk = 35
1414
versionCode = 4
1515
versionName = "0.3"
@@ -67,18 +67,26 @@ dependencies {
6767
implementation(libs.androidx.lifecycle.runtime.ktx)
6868
implementation(libs.androidx.lifecycle.viewmodel.compose)
6969
implementation(libs.androidx.activity.compose)
70+
implementation(libs.androidx.datastore.android)
7071
implementation(platform(libs.androidx.compose.bom))
7172
implementation(libs.androidx.ui)
7273
implementation(libs.androidx.ui.graphics)
7374
implementation(libs.androidx.ui.tooling.preview)
7475
implementation(libs.androidx.material3)
7576
implementation(libs.androidx.graphics.shapes.android)
7677
implementation(libs.androidx.lifecycle.runtime.compose.android)
78+
implementation(libs.androidx.datastore.core.android)
7779
testImplementation(libs.junit)
7880
testImplementation(libs.robolectric)
7981
testImplementation(libs.strikt.core)
82+
testImplementation(libs.kotlinx.coroutines.test)
83+
testImplementation(project(":testutil"))
84+
85+
86+
androidTestImplementation(project(":testutil"))
8087
androidTestImplementation(libs.androidx.junit)
8188
androidTestImplementation(libs.androidx.core.ktx)
89+
androidTestImplementation(libs.strikt.core)
8290
androidTestImplementation(libs.androidx.espresso.core)
8391
androidTestImplementation(platform(libs.androidx.compose.bom))
8492
androidTestImplementation(libs.androidx.ui.test.junit4)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package com.emerjbl.ultra8
2+
3+
import android.content.Context
4+
import androidx.datastore.core.DataStore
5+
import androidx.datastore.dataStore
6+
import androidx.test.ext.junit.runners.AndroidJUnit4
7+
import androidx.test.platform.app.InstrumentationRegistry
8+
import com.emerjbl.ultra8.chip8.graphics.SimpleGraphics
9+
import com.emerjbl.ultra8.chip8.machine.Chip8
10+
import com.emerjbl.ultra8.data.Chip8StateSerializer
11+
import com.emerjbl.ultra8.data.MaybeState
12+
import com.emerjbl.ultra8.testutil.contentEquals
13+
import kotlinx.coroutines.CoroutineScope
14+
import kotlinx.coroutines.Dispatchers
15+
import kotlinx.coroutines.cancel
16+
import kotlinx.coroutines.flow.firstOrNull
17+
import kotlinx.coroutines.runBlocking
18+
import org.junit.Test
19+
import org.junit.runner.RunWith
20+
import strikt.assertions.isNotNull
21+
22+
class DataStoreWrapper(scope: CoroutineScope) {
23+
val Context.testStore: DataStore<MaybeState> by dataStore(
24+
fileName = "chip8state.u8b",
25+
serializer = Chip8StateSerializer,
26+
scope = scope
27+
)
28+
}
29+
30+
@RunWith(AndroidJUnit4::class)
31+
class DataStoreTest {
32+
33+
@Test
34+
fun test_dataStoreReload() {
35+
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
36+
val gfx = SimpleGraphics()
37+
gfx.putSprite(10, 10, byteArrayOf(2, 3, 4, 56, 7, 7), 0, 5, 2)
38+
39+
val state = Chip8.State(
40+
v = (100..115).toList().toIntArray(),
41+
hp = (200..215).toList().toIntArray(),
42+
stack = (300..363).toList().toIntArray(),
43+
mem = (0..65535).map { it.toByte() }.toByteArray(),
44+
i = 0x0242,
45+
pc = 0x0343,
46+
sp = 0x0545,
47+
targetPlane = 0x03,
48+
gfx = gfx,
49+
)
50+
51+
runBlocking(Dispatchers.Default) {
52+
val scope1 = CoroutineScope(Dispatchers.IO)
53+
DataStoreWrapper(scope1).run {
54+
appContext.testStore.updateData { MaybeState.Yes(state) }
55+
}
56+
scope1.cancel()
57+
58+
val scope2 = CoroutineScope(Dispatchers.IO)
59+
val readBack = DataStoreWrapper(scope2).run {
60+
appContext.testStore.data.firstOrNull()
61+
}
62+
scope2.cancel()
63+
64+
strikt.api.expectThat(readBack).isNotNull().contentEquals(MaybeState.Yes(state))
65+
}
66+
}
67+
}

app/src/main/java/com/emerjbl/ultra8/MainActivity.kt

-1
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import com.emerjbl.ultra8.ui.screen.MainScreen
88

99
class MainActivity : ComponentActivity() {
1010
override fun onCreate(savedInstanceState: Bundle?) {
11-
println("ONCREATE")
1211
super.onCreate(savedInstanceState)
1312
actionBar?.hide()
1413
enableEdgeToEdge()

app/src/main/java/com/emerjbl/ultra8/chip8/graphics/SimpleGraphics.kt

+13-6
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,8 @@ import com.emerjbl.ultra8.util.LockGuarded
44
import java.util.concurrent.locks.ReentrantLock
55

66

7-
class SimpleGraphics {
8-
class Frame private constructor(
7+
class SimpleGraphics constructor(hires: Boolean, frame: SimpleGraphics.Frame) {
8+
class Frame constructor(
99
val width: Int,
1010
val height: Int,
1111
val data: IntArray
@@ -15,16 +15,18 @@ class SimpleGraphics {
1515
fun clone(): Frame = Frame(width, height, data.clone())
1616
}
1717

18-
private var frame: LockGuarded<Frame> = LockGuarded(ReentrantLock(), lowRes())
18+
constructor() : this(false, lowRes())
1919

20-
var hires: Boolean = false
20+
fun clone() = SimpleGraphics(hires, frame.withLock { it.clone() })
21+
22+
private var frame: LockGuarded<Frame> = LockGuarded(ReentrantLock(), frame)
23+
24+
var hires: Boolean = hires
2125
set(value) {
2226
field = value
2327
frame.update(if (value) hiRes() else lowRes())
2428
}
2529

26-
private fun lowRes() = Frame(64, 32)
27-
private fun hiRes() = Frame(128, 64)
2830

2931
fun clear() {
3032
frame.withLock { it.data.fill(0) }
@@ -127,4 +129,9 @@ class SimpleGraphics {
127129
frame.clone()
128130
}
129131
}
132+
133+
companion object {
134+
private fun lowRes() = Frame(64, 32)
135+
private fun hiRes() = Frame(128, 64)
136+
}
130137
}

app/src/main/java/com/emerjbl/ultra8/chip8/machine/Chip8.kt

+54-51
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import android.util.Log
44
import com.emerjbl.ultra8.chip8.graphics.Chip8Font
55
import com.emerjbl.ultra8.chip8.graphics.SimpleGraphics
66
import com.emerjbl.ultra8.chip8.input.Chip8Keys
7+
import com.emerjbl.ultra8.chip8.machine.Chip8.State
78
import com.emerjbl.ultra8.chip8.sound.Chip8Sound
89
import com.emerjbl.ultra8.chip8.sound.Pattern
910
import java.nio.ByteBuffer
@@ -12,6 +13,7 @@ import java.util.Random
1213
import kotlin.math.pow
1314
import kotlin.time.TimeSource
1415

16+
1517
private const val EXEC_START: Int = 0x200
1618
private const val FONT_START: Int = 0x000
1719
private const val HIRES_FONT_START: Int = 0x100
@@ -58,18 +60,33 @@ sealed class Halt(val pc: Int) {
5860
}
5961
}
6062

63+
private fun stateFromProgram(program: ByteArray, font: Chip8Font) = State().apply {
64+
font.lo.copyInto(mem, FONT_START)
65+
font.hi.copyInto(mem, HIRES_FONT_START)
66+
program.copyInto(mem, EXEC_START)
67+
}
68+
6169
/** All state of a running Chip8 Machine. */
6270
class Chip8(
6371
private val keys: Chip8Keys,
6472
private val sound: Chip8Sound,
6573
private val font: Chip8Font,
6674
timeSource: TimeSource,
67-
program: ByteArray
75+
private val state: State,
6876
) {
77+
78+
constructor(
79+
keys: Chip8Keys,
80+
sound: Chip8Sound,
81+
font: Chip8Font,
82+
timeSource: TimeSource,
83+
program: ByteArray
84+
) : this(keys, sound, font, timeSource, stateFromProgram(program, font))
85+
6986
/** Collect all Chip8 state in one place. Convenient for eventual save/restore. */
7087
class State(
7188
/** Registers V0-VF. */
72-
internal val v: IntArray = intArrayOf(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
89+
val v: IntArray = intArrayOf(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
7390
/**
7491
* Special HP flag registers.
7592
*
@@ -80,76 +97,62 @@ class Chip8(
8097
*
8198
* These *should* be persisted, but we don't currently do that.
8299
**/
83-
internal val hp: IntArray = intArrayOf(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
100+
val hp: IntArray = intArrayOf(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0),
84101
/** Call stack. */
85-
internal val stack: IntArray = IntArray(64),
102+
val stack: IntArray = IntArray(64),
86103
/**
87104
* Machine memory.
88105
*
89106
* The original machine implementation is 4k, but Chip-XO can address up to 64k.
90107
* For now, we just support the highest.
91108
**/
92-
internal val mem: ByteArray = ByteArray(65536),
109+
val mem: ByteArray = ByteArray(65536),
93110
/** Index register I. */
94-
internal var i: Int = 0,
111+
var i: Int = 0,
95112
/** Stack pointer. */
96-
internal var sp: Int = 0,
113+
var sp: Int = 0,
97114
/** Program counter. */
98-
internal var pc: Int = 0x200,
115+
var pc: Int = 0x200,
99116
/** Chip-XO target plane for drawing (0-3). */
100-
internal var targetPlane: Int = 0x1
117+
var targetPlane: Int = 0x1,
118+
119+
/** Graphics buffer is an important part of state, too. */
120+
val gfx: SimpleGraphics = SimpleGraphics()
101121
) {
102122
/**
103123
* A read-only view of the Chip8 state.
104124
*
105125
* A given instance wraps an actual live machine state, so it will change as the machine does;
106126
* it's not a static copy.
107127
*/
108-
interface View {
109-
val v: IntBuffer
110-
val hp: IntBuffer
111-
val stack: IntBuffer
112-
val mem: ByteBuffer
113-
val i: Int
114-
val sp: Int
115-
val pc: Int
116-
val targetPlane: Int
128+
class View(private val state: State) {
129+
val v = IntBuffer.wrap(state.v).asReadOnlyBuffer()
130+
val hp = IntBuffer.wrap(state.hp).asReadOnlyBuffer()
131+
val stack = IntBuffer.wrap(state.stack).asReadOnlyBuffer()
132+
val mem = ByteBuffer.wrap(state.mem).asReadOnlyBuffer()
133+
val i: Int get() = state.i
134+
val sp: Int get() = state.sp
135+
val pc: Int get() = state.pc
136+
val targetPlane: Int get() = state.targetPlane
137+
138+
/** Create a deep copy of the entire state. */
139+
fun clone() = State(
140+
state.v.clone(),
141+
state.hp.clone(),
142+
state.stack.clone(),
143+
state.mem.clone(),
144+
i,
145+
sp,
146+
pc,
147+
targetPlane,
148+
state.gfx.clone(),
149+
)
117150
}
118-
119-
val stateView = object : View {
120-
override val v: IntBuffer = IntBuffer.wrap(this@State.v).asReadOnlyBuffer()
121-
override val hp: IntBuffer = IntBuffer.wrap(this@State.hp).asReadOnlyBuffer()
122-
override val stack: IntBuffer = IntBuffer.wrap(this@State.stack).asReadOnlyBuffer()
123-
override val mem: ByteBuffer = ByteBuffer.wrap(this@State.mem).asReadOnlyBuffer()
124-
override val i: Int
125-
get() = this@State.i
126-
override val sp: Int
127-
get() = this@State.sp
128-
override val pc: Int
129-
get() = this@State.pc
130-
override val targetPlane: Int
131-
get() = this@State.targetPlane
132-
}
133-
134-
/** Create a deep copy of the entire state. */
135-
fun clone() = State(
136-
v.clone(), hp.clone(), stack.clone(), mem.clone(),
137-
i, sp, pc, targetPlane,
138-
)
139-
140151
}
141152

142-
private val gfx: SimpleGraphics = SimpleGraphics()
153+
val stateView = State.View(state)
143154

144-
fun nextFrame(frame: SimpleGraphics.Frame?): SimpleGraphics.Frame = gfx.nextFrame(frame)
145-
146-
147-
private val state = State().apply {
148-
font.lo.copyInto(mem, FONT_START)
149-
font.hi.copyInto(mem, HIRES_FONT_START)
150-
program.copyInto(mem, EXEC_START)
151-
}
152-
val stateView = state.stateView
155+
fun nextFrame(frame: SimpleGraphics.Frame?): SimpleGraphics.Frame = state.gfx.nextFrame(frame)
153156

154157
private val random: Random = Random()
155158
private val timer: Chip8Timer = Chip8Timer(timeSource)
@@ -166,7 +169,7 @@ class Chip8(
166169
0xE0 -> gfx.clear()
167170

168171
0xEE -> {
169-
if (sp < 0) return Halt.StackUnderflow(pc - 2)
172+
if (sp < 1) return Halt.StackUnderflow(pc - 2)
170173
pc = stack[--sp]
171174
}
172175

0 commit comments

Comments
 (0)