Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
package net.rsprox.cache.store

import com.github.michaelbull.logging.InlineLogger
import io.netty.buffer.ByteBuf
import net.rsprox.cache.Js5MasterIndex
import org.openrs2.cache.DiskStore
import org.openrs2.cache.Store
import org.openrs2.cache.VersionTrailer
import java.nio.file.Files
import java.nio.file.LinkOption
import java.nio.file.Path

public enum class ReplayCacheMatch {
MATCH,
MISMATCH,
UNVERIFIABLE,
}

public class LocalReplayDiskCacheStore(
private val path: Path,
) : ReplayDiskCacheStore {
private val lock: Any = Any()
private var store: Store? = null

override fun get(
archive: Int,
group: Int,
): ByteBuf? {
return synchronized(lock) {
read(openStore(), archive, group)
}
}

override fun open() {
synchronized(lock) {
openStore()
}
}

public fun match(masterIndex: Js5MasterIndex): ReplayCacheMatch {
val buffer = get(Store.ARCHIVESET, Store.ARCHIVESET) ?: return ReplayCacheMatch.UNVERIFIABLE
try {
val actual = ByteArray(buffer.readableBytes())
buffer.getBytes(buffer.readerIndex(), actual)
return if (masterIndex.data contentEquals actual) {
ReplayCacheMatch.MATCH
} else {
ReplayCacheMatch.MISMATCH
}
} finally {
buffer.release()
}
}

override fun close() {
synchronized(lock) {
store?.close()
store = null
}
}

private fun openStore(): Store {
val existing = store
if (existing != null) {
return existing
}
require(Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS)) {
"Path does not point to a cache directory: $path"
}
require(Files.isRegularFile(path.resolve(DATA_FILE), LinkOption.NOFOLLOW_LINKS)) {
"Cache directory does not contain $DATA_FILE: $path"
}
val opened = DiskStore.open(path)
store = opened
return opened
}

private fun read(
store: Store,
archive: Int,
group: Int,
): ByteBuf? {
return try {
val buffer = store.read(archive, group)
if (archive != Store.ARCHIVESET) {
VersionTrailer.strip(buffer)
}
buffer
} catch (error: Exception) {
logger.debug(error) { "Unable to read local replay cache group $archive:$group from $path" }
null
}
}

private companion object {
private const val DATA_FILE: String = "main_file_cache.dat2"
private val logger = InlineLogger()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package net.rsprox.cache.store

import io.netty.buffer.Unpooled
import net.rsprox.cache.Js5MasterIndex
import org.openrs2.cache.DiskStore
import org.openrs2.cache.Store
import java.nio.file.Files
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith

class LocalReplayDiskCacheStoreTest {
@Test
fun `opens disk cache and compares its master index`() {
val cacheDirectory = Files.createTempDirectory("rsprox-local-replay-cache")
val expected = byteArrayOf(1, 2, 3, 4)
DiskStore.create(cacheDirectory).use { store ->
store.write(Store.ARCHIVESET, Store.ARCHIVESET, Unpooled.wrappedBuffer(expected))
}

LocalReplayDiskCacheStore(cacheDirectory).use { store ->
store.open()
assertEquals(ReplayCacheMatch.MATCH, store.match(Js5MasterIndex(240, expected)))
assertEquals(ReplayCacheMatch.MISMATCH, store.match(Js5MasterIndex(240, byteArrayOf(9))))
}

val invalidDirectory = Files.createTempDirectory("rsprox-invalid-replay-cache")
assertFailsWith<IllegalArgumentException> {
LocalReplayDiskCacheStore(invalidDirectory).open()
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package net.rsprox.gui

import java.nio.file.Files
import java.nio.file.LinkOption
import java.nio.file.Path
import java.util.prefs.Preferences

internal class RecentReplayCaches(
private val preferences: Preferences =
Preferences.userNodeForPackage(App::class.java).node("recentReplayCaches"),
) {
fun record(path: Path) {
val normalized = normalize(path)
save(
buildList {
add(normalized)
addAll(load().filterNot { it == normalized })
}.take(MAX_RECENTS),
)
}

fun remove(path: Path) {
val normalized = normalize(path)
save(load().filterNot { it == normalized })
}

fun list(): List<Path> {
val valid = load().filter(::isCacheDirectory).take(MAX_RECENTS)
save(valid)
return valid
}

private fun load(): List<Path> {
return preferences
.get(RECENTS_KEY, "")
.lineSequence()
.map(String::trim)
.filter(String::isNotEmpty)
.map(Path::of)
.map(::normalize)
.distinct()
.toList()
}

private fun save(paths: List<Path>) {
preferences.put(RECENTS_KEY, paths.joinToString("\n"))
runCatching { preferences.flush() }
}

private fun normalize(path: Path): Path =
runCatching { path.toRealPath() }
.getOrElse { path.toAbsolutePath().normalize() }

private fun isCacheDirectory(path: Path): Boolean =
Files.isDirectory(path, LinkOption.NOFOLLOW_LINKS) &&
Files.isRegularFile(path.resolve(DATA_FILE), LinkOption.NOFOLLOW_LINKS)

private companion object {
private const val RECENTS_KEY: String = "paths"
private const val MAX_RECENTS: Int = 5
private const val DATA_FILE: String = "main_file_cache.dat2"
}
}
154 changes: 152 additions & 2 deletions gui/proxy-tool/src/main/kotlin/net/rsprox/gui/ReplayPanel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import com.formdev.flatlaf.util.ColorFunctions
import com.formdev.flatlaf.util.SystemFileChooser
import com.github.michaelbull.logging.InlineLogger
import net.miginfocom.swing.MigLayout
import net.rsprox.cache.Js5MasterIndex
import net.rsprox.cache.store.LocalReplayDiskCacheStore
import net.rsprox.cache.store.ReplayCacheMatch
import net.rsprox.cache.store.ReplayDiskCacheStore
import net.rsprox.gui.sessions.SessionType
import net.rsprox.proxy.binary.BinaryHeader
import net.rsprox.proxy.config.BINARY_PATH
Expand Down Expand Up @@ -41,6 +45,7 @@ import java.nio.file.Path
import java.text.DateFormat
import java.util.Date
import java.util.concurrent.ForkJoinPool
import java.util.concurrent.FutureTask
import javax.swing.BorderFactory
import javax.swing.DefaultComboBoxModel
import javax.swing.JOptionPane
Expand All @@ -61,6 +66,7 @@ public class ReplayPanel(
) : JPanel() {
private var replaySession: ReplaySession? = null
private var selectedPath: Path? = null
private val recentReplayCaches = RecentReplayCaches()
private val titleLabel = FlatLabel()
private val detailsLabel = FlatLabel()
private val statusLabel = FlatLabel()
Expand Down Expand Up @@ -178,8 +184,14 @@ public class ReplayPanel(
ForkJoinPool.commonPool().submit {
var session: ReplaySession? = null
try {
session = App.service.loadReplaySession(path)
val transcript = App.service.transcribeReplaySession(session)
val loadedSession =
App.service.loadReplaySession(path, ::selectManualReplayCache)
?: run {
finishCancelledReplayLoad(generation)
return@submit
}
session = loadedSession
val transcript = App.service.transcribeReplaySession(loadedSession)
SwingUtilities.invokeLater {
if (generation != loadGeneration) {
session.close()
Expand Down Expand Up @@ -221,6 +233,143 @@ public class ReplayPanel(
}
}

private fun selectManualReplayCache(masterIndex: Js5MasterIndex): ReplayDiskCacheStore? {
while (true) {
val recents = recentReplayCaches.list()
val path =
onEventDispatchThread {
chooseManualReplayCache(masterIndex.revision, recents)
} ?: return null
val store = LocalReplayDiskCacheStore(path)
try {
store.open()
} catch (error: Exception) {
store.close()
recentReplayCaches.remove(path)
onEventDispatchThread {
JOptionPane.showMessageDialog(
this,
error.message ?: "Unable to open the selected cache directory.",
"Invalid Replay Cache",
JOptionPane.ERROR_MESSAGE,
)
}
continue
}

when (val match = store.match(masterIndex)) {
ReplayCacheMatch.MATCH -> {
recentReplayCaches.record(path)
return store
}
else -> {
when (confirmManualReplayCache(match, path)) {
true -> {
recentReplayCaches.record(path)
return store
}
false -> store.close()
null -> {
store.close()
return null
}
}
}
}
}
}

private fun confirmManualReplayCache(
match: ReplayCacheMatch,
path: Path,
): Boolean? {
return onEventDispatchThread {
val detail =
if (match == ReplayCacheMatch.MISMATCH) "does not match"
else "could not be verified against"
val options = arrayOf("Use Anyway", "Choose Another", "Cancel")
when (
JOptionPane.showOptionDialog(
this,
"The cache at:\n$path\n\n$detail the master index embedded in this replay.",
"Replay Cache Mismatch",
JOptionPane.DEFAULT_OPTION,
JOptionPane.WARNING_MESSAGE,
null,
options,
options[1],
)
) {
0 -> true
1 -> false
else -> null
}
}
}

private fun chooseManualReplayCache(
revision: Int,
recents: List<Path>,
): Path? {
if (recents.isEmpty()) {
return browseForManualReplayCache(revision)
}
val browse = "Browse..."
val choices = (recents + browse).toTypedArray()
val selected =
JOptionPane.showInputDialog(
this,
"Select the disk cache used to record this replay.",
"Select Replay Cache (Revision $revision)",
JOptionPane.QUESTION_MESSAGE,
null,
choices,
choices.first(),
) ?: return null
return if (selected == browse) {
browseForManualReplayCache(revision)
} else {
selected as Path
}
}

private fun browseForManualReplayCache(revision: Int): Path? {
val chooser =
SystemFileChooser().apply {
stateStoreID = REPLAY_CACHE_FILE_CHOOSER_STATE_ID
dialogTitle = "Select Replay Cache (Revision $revision)"
fileSelectionMode = SystemFileChooser.DIRECTORIES_ONLY
isAcceptAllFileFilterUsed = false
}
if (chooser.showOpenDialog(this) != SystemFileChooser.APPROVE_OPTION) {
return null
}
return chooser.selectedFile?.toPath()
}

private fun finishCancelledReplayLoad(generation: Int) {
SwingUtilities.invokeLater {
if (generation != loadGeneration) {
return@invokeLater
}
loadingReplay = false
launchingReplayClient = false
loadingProgress.isVisible = false
selectedPath = null
updateEmptyState()
refreshControls()
}
}

private fun <T> onEventDispatchThread(action: () -> T): T {
if (SwingUtilities.isEventDispatchThread()) {
return action()
}
val task = FutureTask<T> { action() }
SwingUtilities.invokeAndWait(task)
return task.get()
}

private fun updateInitialWindowMode(
windowMode: Int,
gameRevision: Int,
Expand Down Expand Up @@ -1202,6 +1351,7 @@ public class ReplayPanel(
private const val GAME_TICK_MILLIS = 600
private const val DEFAULT_REPLAY_BINARY_FOLDER = "Old School RuneScape"
private const val REPLAY_FILE_CHOOSER_STATE_ID = "replayFile"
private const val REPLAY_CACHE_FILE_CHOOSER_STATE_ID = "replayCacheDirectory"
private const val SKIP_TICKS = 10
}

Expand Down
Loading
Loading