Added several tests and fixed some bugs.

This commit is contained in:
Daan Vanden Bosch 2020-12-23 21:48:53 +01:00
parent 87ab6506cf
commit 1b0a8781b3
213 changed files with 741 additions and 67 deletions

View File

@ -6,12 +6,12 @@ function ResourceLoaderMiddleware() {
return function (request, response, next) { return function (request, response, next) {
try { try {
const content = fs.readFileSync(PROJECT_PATH + '/build/processedResources/js/test' + request.originalUrl); const content = fs.readFileSync(PROJECT_PATH + '/build/processedResources/js/test' + decodeURI(request.originalUrl));
response.writeHead(200); response.writeHead(200);
response.end(content); response.end(content);
} catch (ignored) { } catch (ignored) {
try { try {
const content = fs.readFileSync(PROJECT_PATH + '/build/processedResources/js/main' + request.originalUrl); const content = fs.readFileSync(PROJECT_PATH + '/build/processedResources/js/main' + decodeURI(request.originalUrl));
response.writeHead(200); response.writeHead(200);
response.end(content); response.end(content);
} catch (ignored) { } catch (ignored) {

View File

@ -198,6 +198,7 @@ private class Assembler(private val asm: List<String>, private val inlineStackAr
segment = StringSegment( segment = StringSegment(
labels = mutableListOf(), labels = mutableListOf(),
value = str, value = str,
bytecodeSize = null,
srcLoc = SegmentSrcLoc() srcLoc = SegmentSrcLoc()
) )
@ -313,6 +314,7 @@ private class Assembler(private val asm: List<String>, private val inlineStackAr
segment = StringSegment( segment = StringSegment(
labels = mutableListOf(label), labels = mutableListOf(label),
value = "", value = "",
bytecodeSize = null,
srcLoc = SegmentSrcLoc(labels = mutableListOf(srcLoc)), srcLoc = SegmentSrcLoc(labels = mutableListOf(srcLoc)),
) )
ir.add(segment!!) ir.add(segment!!)

View File

@ -1,6 +1,7 @@
package world.phantasmal.lib.asm package world.phantasmal.lib.asm
import world.phantasmal.lib.buffer.Buffer import world.phantasmal.lib.buffer.Buffer
import kotlin.math.ceil
/** /**
* Intermediate representation of PSO bytecode. Used by most ASM/bytecode analysis code. * Intermediate representation of PSO bytecode. Used by most ASM/bytecode analysis code.
@ -31,6 +32,7 @@ sealed class Segment(
val labels: MutableList<Int>, val labels: MutableList<Int>,
val srcLoc: SegmentSrcLoc, val srcLoc: SegmentSrcLoc,
) { ) {
abstract fun size(dcGcFormat: Boolean): Int
abstract fun copy(): Segment abstract fun copy(): Segment
} }
@ -39,6 +41,9 @@ class InstructionSegment(
val instructions: MutableList<Instruction>, val instructions: MutableList<Instruction>,
srcLoc: SegmentSrcLoc = SegmentSrcLoc(mutableListOf()), srcLoc: SegmentSrcLoc = SegmentSrcLoc(mutableListOf()),
) : Segment(SegmentType.Instructions, labels, srcLoc) { ) : Segment(SegmentType.Instructions, labels, srcLoc) {
override fun size(dcGcFormat: Boolean): Int =
instructions.sumBy { it.getSize(dcGcFormat) }
override fun copy(): InstructionSegment = override fun copy(): InstructionSegment =
InstructionSegment( InstructionSegment(
ArrayList(labels), ArrayList(labels),
@ -52,17 +57,40 @@ class DataSegment(
val data: Buffer, val data: Buffer,
srcLoc: SegmentSrcLoc = SegmentSrcLoc(mutableListOf()), srcLoc: SegmentSrcLoc = SegmentSrcLoc(mutableListOf()),
) : Segment(SegmentType.Data, labels, srcLoc) { ) : Segment(SegmentType.Data, labels, srcLoc) {
override fun size(dcGcFormat: Boolean): Int =
data.size
override fun copy(): DataSegment = override fun copy(): DataSegment =
DataSegment(ArrayList(labels), data.copy(), srcLoc.copy()) DataSegment(ArrayList(labels), data.copy(), srcLoc.copy())
} }
class StringSegment( class StringSegment(
labels: MutableList<Int>, labels: MutableList<Int>,
var value: String, value: String,
/**
* Normally string segments have a byte length that is a multiple of 4, but some bytecode is
* malformed so we store the initial size in the bytecode.
*/
private var bytecodeSize: Int?,
srcLoc: SegmentSrcLoc = SegmentSrcLoc(mutableListOf()), srcLoc: SegmentSrcLoc = SegmentSrcLoc(mutableListOf()),
) : Segment(SegmentType.String, labels, srcLoc) { ) : Segment(SegmentType.String, labels, srcLoc) {
var value: String = value
set(value) {
bytecodeSize = null
field = value
}
override fun size(dcGcFormat: Boolean): Int =
// String segments should be multiples of 4 bytes.
bytecodeSize
?: if (dcGcFormat) {
4 * ceil((value.length + 1) / 4.0).toInt()
} else {
4 * ceil((value.length + 1) / 2.0).toInt()
}
override fun copy(): StringSegment = override fun copy(): StringSegment =
StringSegment(ArrayList(labels), value, srcLoc.copy()) StringSegment(ArrayList(labels), value, bytecodeSize, srcLoc.copy())
} }
/** /**

View File

@ -9,6 +9,7 @@ private val logger = KotlinLogging.logger {}
/** /**
* Computes the possible values of a register right before a specific instruction. * Computes the possible values of a register right before a specific instruction.
* TODO: Deal with function calls.
*/ */
fun getRegisterValue(cfg: ControlFlowGraph, instruction: Instruction, register: Int): ValueSet { fun getRegisterValue(cfg: ControlFlowGraph, instruction: Instruction, register: Int): ValueSet {
require(register in 0..255) { require(register in 0..255) {
@ -51,6 +52,11 @@ private class RegisterValueFinder {
return ValueSet.all() return ValueSet.all()
} }
OP_VA_CALL.code -> {
val value = vaCall(path, block, i, register)
if (value.isNotEmpty()) return value
}
OP_LET.code -> { OP_LET.code -> {
if (args[0].value == register) { if (args[0].value == register) {
return find(LinkedHashSet(path), block, i, args[1].value as Int) return find(LinkedHashSet(path), block, i, args[1].value as Int)
@ -224,4 +230,67 @@ private class RegisterValueFinder {
return values return values
} }
/**
* After a va_start instruction, 0 or more arg_push instructions can be used. When va_call is
* executed the values on the stack will become the values of registers r1..r7 (inclusive) in
* the order that they were pushed.
*
* E.g.:
*
* va_start
* arg_pushl 10
* arg_pushl 20
* va_call 777
* va_end
*
* This means call 777 with r1 = 10 and r2 = 20.
*/
private fun vaCall(
path: MutableSet<BasicBlock>,
block: BasicBlock,
vaCallIdx: Int,
register: Int,
): ValueSet {
if (register !in 1..7) return ValueSet.empty()
var vaStartIdx = -1
// Pairs of type and value.
val stack = mutableListOf<Pair<AnyType, Any>>()
for (i in block.start until vaCallIdx) {
val instruction = block.segment.instructions[i]
val opcode = instruction.opcode
if (opcode.code == OP_VA_START.code) {
vaStartIdx = i
} else if (vaStartIdx != -1) {
val type = when (opcode.code) {
OP_ARG_PUSHR.code -> RegRefType
OP_ARG_PUSHL.code -> IntType
OP_ARG_PUSHB.code -> ByteType
OP_ARG_PUSHW.code -> ShortType
OP_ARG_PUSHA.code -> PointerType
OP_ARG_PUSHO.code -> PointerType
OP_ARG_PUSHS.code -> StringType
else -> continue
}
stack.add(Pair(type, instruction.args[0].value))
}
}
return if (register in 1..stack.size) {
val (type, value) = stack[register - 1]
when (type) {
RegRefType -> find(LinkedHashSet(path), block, vaStartIdx, value as Int)
IntType, ByteType, ShortType -> ValueSet.of(value as Int)
// TODO: Deal with strings.
else -> ValueSet.all() // String or pointer
}
} else {
ValueSet.of(0)
}
}
} }

View File

@ -8,6 +8,7 @@ private val logger = KotlinLogging.logger {}
/** /**
* Computes the possible values of a stack element at the nth position from the top, right before a * Computes the possible values of a stack element at the nth position from the top, right before a
* specific instruction. * specific instruction.
* TODO: Deal with va_call.
*/ */
fun getStackValue(cfg: ControlFlowGraph, instruction: Instruction, position: Int): ValueSet { fun getStackValue(cfg: ControlFlowGraph, instruction: Instruction, position: Int): ValueSet {
val block = cfg.getBlockForInstruction(instruction) val block = cfg.getBlockForInstruction(instruction)

View File

@ -29,6 +29,9 @@ class ValueSet private constructor(private val intervals: MutableList<Interval>)
fun isEmpty(): Boolean = fun isEmpty(): Boolean =
intervals.isEmpty() intervals.isEmpty()
fun isNotEmpty(): Boolean =
intervals.isNotEmpty()
fun minOrNull(): Int? = fun minOrNull(): Int? =
intervals.firstOrNull()?.start intervals.firstOrNull()?.start

View File

@ -12,7 +12,6 @@ import world.phantasmal.lib.buffer.Buffer
import world.phantasmal.lib.cursor.BufferCursor import world.phantasmal.lib.cursor.BufferCursor
import world.phantasmal.lib.cursor.Cursor import world.phantasmal.lib.cursor.Cursor
import world.phantasmal.lib.cursor.cursor import world.phantasmal.lib.cursor.cursor
import kotlin.math.ceil
import kotlin.math.min import kotlin.math.min
private val logger = KotlinLogging.logger {} private val logger = KotlinLogging.logger {}
@ -24,7 +23,8 @@ val SEGMENT_PRIORITY = mapOf(
) )
/** /**
* These functions are built into the client and can optionally be overridden on BB. * These functions are built into the client and can optionally be overridden on BB. Other versions
* require you to always specify them in the script.
*/ */
val BUILTIN_FUNCTIONS = setOf( val BUILTIN_FUNCTIONS = setOf(
60, 60,
@ -43,6 +43,13 @@ val BUILTIN_FUNCTIONS = setOf(
840, 840,
850, 850,
860, 860,
900,
910,
920,
930,
940,
950,
960,
) )
/** /**
@ -114,20 +121,7 @@ fun parseBytecode(
segments.add(segment) segments.add(segment)
offset += when (segment) { offset += segment.size(dcGcFormat)
is InstructionSegment -> segment.instructions.sumBy { it.getSize(dcGcFormat) }
is DataSegment -> segment.data.size
// String segments should be multiples of 4 bytes.
is StringSegment -> {
if (dcGcFormat) {
4 * ceil((segment.value.length + 1) / 4.0).toInt()
} else {
4 * ceil((segment.value.length + 1) / 2.0).toInt()
}
}
}
} }
// Add unreferenced labels to their segment. // Add unreferenced labels to their segment.
@ -174,11 +168,14 @@ private fun findAndParseSegments(
) { ) {
var newLabels = labels var newLabels = labels
var startSegmentCount: Int var startSegmentCount: Int
// Instruction segments which we've been able to fully analyze for label references so far.
val analyzedSegments = mutableSetOf<InstructionSegment>()
// Iteratively parse segments from label references. // Iteratively parse segments from label references.
do { do {
startSegmentCount = offsetToSegment.size startSegmentCount = offsetToSegment.size
// Parse segments of which the type is known.
for ((label, type) in newLabels) { for ((label, type) in newLabels) {
parseSegment(offsetToSegment, labelHolder, cursor, label, type, lenient, dcGcFormat) parseSegment(offsetToSegment, labelHolder, cursor, label, type, lenient, dcGcFormat)
} }
@ -194,21 +191,31 @@ private fun findAndParseSegments(
newLabels = mutableMapOf() newLabels = mutableMapOf()
for (segment in sortedSegments) { for (segment in sortedSegments) {
for (instruction in segment.instructions) { if (segment in analyzedSegments) continue
var foundAllLabels = true
for (instructionIdx in segment.instructions.indices) {
val instruction = segment.instructions[instructionIdx]
var i = 0 var i = 0
while (i < instruction.opcode.params.size) { while (i < instruction.opcode.params.size) {
val param = instruction.opcode.params[i] val param = instruction.opcode.params[i]
when (param.type) { when (param.type) {
is ILabelType -> is ILabelType -> {
getArgLabelValues( if (!getArgLabelValues(
cfg, cfg,
newLabels, newLabels,
instruction, segment,
i, instructionIdx,
SegmentType.Instructions, i,
) SegmentType.Instructions,
)
) {
foundAllLabels = false
}
}
is ILabelVarType -> { is ILabelVarType -> {
// Never on the stack. // Never on the stack.
@ -220,11 +227,33 @@ private fun findAndParseSegments(
} }
} }
is DLabelType -> is DLabelType -> {
getArgLabelValues(cfg, newLabels, instruction, i, SegmentType.Data) if (!getArgLabelValues(
cfg,
newLabels,
segment,
instructionIdx,
i,
SegmentType.Data
)
) {
foundAllLabels = false
}
}
is SLabelType -> is SLabelType -> {
getArgLabelValues(cfg, newLabels, instruction, i, SegmentType.String) if (!getArgLabelValues(
cfg,
newLabels,
segment,
instructionIdx,
i,
SegmentType.String
)
) {
foundAllLabels = false
}
}
is RegTupRefType -> { is RegTupRefType -> {
for (j in param.type.registerTuple.indices) { for (j in param.type.registerTuple.indices) {
@ -239,10 +268,12 @@ private fun findAndParseSegments(
firstRegister + j, firstRegister + j,
) )
if (labelValues.size <= 10) { if (labelValues.size <= 20) {
for (label in labelValues) { for (label in labelValues) {
newLabels[label] = SegmentType.Instructions newLabels[label] = SegmentType.Instructions
} }
} else {
foundAllLabels = false
} }
} }
} }
@ -252,6 +283,10 @@ private fun findAndParseSegments(
i++ i++
} }
} }
if (foundAllLabels) {
analyzedSegments.add(segment)
}
} }
} while (offsetToSegment.size > startSegmentCount) } while (offsetToSegment.size > startSegmentCount)
} }
@ -262,10 +297,13 @@ private fun findAndParseSegments(
private fun getArgLabelValues( private fun getArgLabelValues(
cfg: ControlFlowGraph, cfg: ControlFlowGraph,
labels: MutableMap<Int, SegmentType>, labels: MutableMap<Int, SegmentType>,
instruction: Instruction, instructionSegment: InstructionSegment,
instructionIdx: Int,
paramIdx: Int, paramIdx: Int,
segmentType: SegmentType, segmentType: SegmentType,
) { ): Boolean {
val instruction = instructionSegment.instructions[instructionIdx]
if (instruction.opcode.stack === StackInteraction.Pop) { if (instruction.opcode.stack === StackInteraction.Pop) {
val stackValues = getStackValue( val stackValues = getStackValue(
cfg, cfg,
@ -273,7 +311,7 @@ private fun getArgLabelValues(
instruction.opcode.params.size - paramIdx - 1, instruction.opcode.params.size - paramIdx - 1,
) )
if (stackValues.size <= 10) { if (stackValues.size <= 20) {
for (value in stackValues) { for (value in stackValues) {
val oldType = labels[value] val oldType = labels[value]
@ -284,6 +322,8 @@ private fun getArgLabelValues(
labels[value] = segmentType labels[value] = segmentType
} }
} }
return true
} }
} else { } else {
val value = instruction.args[paramIdx].value as Int val value = instruction.args[paramIdx].value as Int
@ -293,9 +333,14 @@ private fun getArgLabelValues(
oldType == null || oldType == null ||
SEGMENT_PRIORITY.getValue(segmentType) > SEGMENT_PRIORITY.getValue(oldType) SEGMENT_PRIORITY.getValue(segmentType) > SEGMENT_PRIORITY.getValue(oldType)
) { ) {
// println("Type of label $value inferred as $segmentType because of parameter ${paramIdx + 1} of instruction ${instructionIdx + 1} ${instruction.opcode.mnemonic} at label ${instructionSegment.labels.firstOrNull()}.")
labels[value] = segmentType labels[value] = segmentType
} }
return true
} }
return false
} }
private fun parseSegment( private fun parseSegment(
@ -416,10 +461,10 @@ private fun parseInstructionsSegment(
// Recurse on label drop-through. // Recurse on label drop-through.
if (nextLabel != null) { if (nextLabel != null) {
// Find the first ret or jmp. // Find the last ret or jmp.
var dropThrough = true var dropThrough = true
for (i in instructions.size - 1 downTo 0) { for (i in instructions.lastIndex downTo 0) {
val opcode = instructions[i].opcode.code val opcode = instructions[i].opcode.code
if (opcode == OP_RET.code || opcode == OP_JMP.code) { if (opcode == OP_RET.code || opcode == OP_JMP.code) {
@ -465,21 +510,23 @@ private fun parseStringSegment(
dcGcFormat: Boolean, dcGcFormat: Boolean,
) { ) {
val startOffset = cursor.position val startOffset = cursor.position
val byteLength = endOffset - startOffset
val segment = StringSegment( val segment = StringSegment(
labels, labels,
if (dcGcFormat) { if (dcGcFormat) {
cursor.stringAscii( cursor.stringAscii(
endOffset - startOffset, byteLength,
nullTerminated = true, nullTerminated = true,
dropRemaining = true dropRemaining = true
) )
} else { } else {
cursor.stringUtf16( cursor.stringUtf16(
endOffset - startOffset, byteLength,
nullTerminated = true, nullTerminated = true,
dropRemaining = true dropRemaining = true
) )
}, },
byteLength,
SegmentSrcLoc() SegmentSrcLoc()
) )
offsetToSegment[startOffset] = segment offsetToSegment[startOffset] = segment
@ -593,7 +640,14 @@ fun writeBytecode(bytecodeIr: BytecodeIr, dcGcFormat: Boolean): BytecodeAndLabel
for (i in opcode.params.indices) { for (i in opcode.params.indices) {
val param = opcode.params[i] val param = opcode.params[i]
val args = instruction.getArgs(i) val args = instruction.getArgs(i)
val arg = args.first() val arg = args.firstOrNull()
if (arg == null) {
logger.warn {
"No argument passed to ${opcode.mnemonic} for parameter ${i + 1}."
}
continue
}
when (param.type) { when (param.type) {
ByteType -> cursor.writeByte((arg.value as Int).toByte()) ByteType -> cursor.writeByte((arg.value as Int).toByte())
@ -638,11 +692,9 @@ fun writeBytecode(bytecodeIr: BytecodeIr, dcGcFormat: Boolean): BytecodeAndLabel
is StringSegment -> { is StringSegment -> {
// String segments should be multiples of 4 bytes. // String segments should be multiples of 4 bytes.
if (dcGcFormat) { if (dcGcFormat) {
val byteLength = 4 * ceil((segment.value.length + 1) / 4.0).toInt() cursor.writeStringAscii(segment.value, segment.size(dcGcFormat))
cursor.writeStringAscii(segment.value, byteLength)
} else { } else {
val byteLength = 4 * ceil((segment.value.length + 1) / 2.0).toInt() cursor.writeStringUtf16(segment.value, segment.size(dcGcFormat))
cursor.writeStringUtf16(segment.value, byteLength)
} }
} }

View File

@ -280,7 +280,7 @@ private fun writeEntities(
) { ) {
val groupedEntities = entities.groupBy { it.areaId } val groupedEntities = entities.groupBy { it.areaId }
for ((areaId, areaEntities) in groupedEntities.entries.sortedBy { it.key }) { for ((areaId, areaEntities) in groupedEntities.entries) {
val entitiesSize = areaEntities.size * entitySize val entitiesSize = areaEntities.size * entitySize
cursor.writeInt(entityType) cursor.writeInt(entityType)
cursor.writeInt(16 + entitiesSize) cursor.writeInt(16 + entitiesSize)
@ -309,7 +309,7 @@ private fun writeEntities(
private fun writeEvents(cursor: WritableCursor, events: List<DatEvent>) { private fun writeEvents(cursor: WritableCursor, events: List<DatEvent>) {
val groupedEvents = events.groupBy { it.areaId } val groupedEvents = events.groupBy { it.areaId }
for ((areaId, areaEvents) in groupedEvents.entries.sortedBy { it.key }) { for ((areaId, areaEvents) in groupedEvents.entries) {
// Standard header. // Standard header.
cursor.writeInt(3) // Entity type cursor.writeInt(3) // Entity type
val totalSizeOffset = cursor.position val totalSizeOffset = cursor.position

View File

@ -265,6 +265,7 @@ fun writeQuestToQst(quest: Quest, filename: String, version: Version, online: Bo
)) ))
val baseFilename = (filenameBase(filename) ?: filename).take(11) val baseFilename = (filenameBase(filename) ?: filename).take(11)
val questName = quest.name.take(if (version == Version.BB) 23 else 31)
return writeQst(QstContent( return writeQst(QstContent(
version, version,
@ -273,13 +274,13 @@ fun writeQuestToQst(quest: Quest, filename: String, version: Version, online: Bo
QstContainedFile( QstContainedFile(
id = quest.id, id = quest.id,
filename = "$baseFilename.dat", filename = "$baseFilename.dat",
questName = quest.name, questName = questName,
data = prsCompress(dat.cursor()).buffer(), data = prsCompress(dat.cursor()).buffer(),
), ),
QstContainedFile( QstContainedFile(
id = quest.id, id = quest.id,
filename = "$baseFilename.bin", filename = "$baseFilename.bin",
questName = quest.name, questName = questName,
data = prsCompress(bin.cursor()).buffer(), data = prsCompress(bin.cursor()).buffer(),
), ),
), ),

View File

@ -200,4 +200,52 @@ class GetRegisterValueTests : LibTestSuite() {
assertEquals(23, v2[3]) assertEquals(23, v2[3])
assertEquals(24, v2[4]) assertEquals(24, v2[4])
} }
@Test
fun va_call() {
val im = toInstructions("""
0:
va_start
arg_pushl 42
va_call 100
va_end
ret
100:
ret
""".trimIndent())
val cfg = ControlFlowGraph.create(im)
val value = getRegisterValue(cfg, im[1].instructions[0], 1)
assertEquals(1, value.size)
assertEquals(42, value[0])
}
@Test
fun multiple_va_call() {
val im = toInstructions("""
0:
va_start
arg_pushl 1
va_call 100
va_end
va_start
arg_pushl 2
va_call 100
va_end
va_start
arg_pushl 3
va_call 100
va_end
ret
100:
ret
""".trimIndent())
val cfg = ControlFlowGraph.create(im)
val value = getRegisterValue(cfg, im[1].instructions[0], 1)
assertEquals(3, value.size)
assertEquals(1, value[0])
assertEquals(2, value[1])
assertEquals(3, value[2])
}
} }

View File

@ -1,7 +1,10 @@
package world.phantasmal.lib.fileFormats.quest package world.phantasmal.lib.fileFormats.quest
import world.phantasmal.lib.cursor.cursor
import world.phantasmal.lib.test.LibTestSuite import world.phantasmal.lib.test.LibTestSuite
import world.phantasmal.lib.test.assertDeepEquals
import world.phantasmal.lib.test.readFile import world.phantasmal.lib.test.readFile
import world.phantasmal.lib.test.testWithTetheallaQuests
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
@ -22,4 +25,40 @@ class QstTests : LibTestSuite() {
assertEquals("quest58.dat", qst.files[1].filename) assertEquals("quest58.dat", qst.files[1].filename)
assertEquals("PSO/Lost HEAT SWORD", qst.files[1].questName) assertEquals("PSO/Lost HEAT SWORD", qst.files[1].questName)
} }
/**
* Parse a file, convert the resulting structure to QST again and check whether the end result
* is byte-for-byte equal to the original.
*/
@Test
fun parseQst_and_writeQst_with_all_tethealla_quests() = asyncTest {
testWithTetheallaQuests { path, _ ->
if (EXCLUDED.any { it in path }) return@testWithTetheallaQuests
try {
val origQst = readFile(path)
val parsedQst = parseQst(origQst).unwrap()
val newQst = writeQst(parsedQst)
origQst.seekStart(0)
assertDeepEquals(origQst, newQst.cursor())
} catch (e: Throwable) {
throw Exception("""Failed for "$path": ${e.message}""", e)
}
}
}
companion object {
// TODO: Figure out why we can't round-trip these quests.
private val EXCLUDED = listOf(
"/ep2/shop/gallon.qst",
"/princ/ep1/",
"/princ/ep4/",
"/solo/ep1/04.qst", // Skip because it contains every chuck twice.
"/fragmentofmemoryen.qst",
"/lost havoc vulcan.qst",
"/goodluck.qst",
".raw",
)
}
} }

View File

@ -8,6 +8,7 @@ import world.phantasmal.lib.cursor.cursor
import world.phantasmal.lib.test.LibTestSuite import world.phantasmal.lib.test.LibTestSuite
import world.phantasmal.lib.test.assertDeepEquals import world.phantasmal.lib.test.assertDeepEquals
import world.phantasmal.lib.test.readFile import world.phantasmal.lib.test.readFile
import world.phantasmal.lib.test.testWithTetheallaQuests
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue import kotlin.test.assertTrue
@ -107,8 +108,21 @@ class QuestTests : LibTestSuite() {
roundTripTest(filename, readFile("/$filename")) roundTripTest(filename, readFile("/$filename"))
} }
// TODO: Figure out why this test is so slow in JS/Karma.
@Test
fun round_trip_test_with_all_tethealla_quests() = asyncTest(slow = true) {
testWithTetheallaQuests { path, filename ->
if (EXCLUDED.any { it in path }) return@testWithTetheallaQuests
try {
roundTripTest(filename, readFile(path))
} catch (e: Throwable) {
throw Exception("""Failed for "$path": ${e.message}""", e)
}
}
}
/** /**
* Round-trip tests.
* Parse a QST file, write the resulting Quest object to QST again, then parse that again. * Parse a QST file, write the resulting Quest object to QST again, then parse that again.
* Then check whether the two Quest objects are deeply equal. * Then check whether the two Quest objects are deeply equal.
*/ */
@ -155,6 +169,20 @@ class QuestTests : LibTestSuite() {
} }
assertDeepEquals(origQuest.mapDesignations, newQuest.mapDesignations, ::assertEquals) assertDeepEquals(origQuest.mapDesignations, newQuest.mapDesignations, ::assertEquals)
assertDeepEquals(origQuest.bytecodeIr, newQuest.bytecodeIr) assertDeepEquals(origQuest.bytecodeIr, newQuest.bytecodeIr, ignoreSrcLocs = true)
}
companion object {
private val EXCLUDED = listOf(
".raw",
// TODO: Test challenge mode quests when they're supported.
"/chl/",
// Central Dome Fire Swirl seems to be corrupt for two reasons:
// - It's ID is 33554458, according to the .bin, which is too big for the .qst format.
// - It has an NPC with script label 100, but the code at that label is invalid.
"/solo/ep1/side/26.qst",
// PRS-compressed file seems corrupt in Gallon's Plan, but qedit has no issues with it.
"/solo/ep1/side/quest035.qst",
)
} }
} }

View File

@ -0,0 +1,181 @@
package world.phantasmal.lib.test
/**
* Applies [process] to all quest files provided with Tethealla version 0.143.
* [process] is called with the path to the file and the file name.
*/
inline fun testWithTetheallaQuests(process: (path: String, filename: String) -> Unit) {
for (file in TETHEALLA_QUESTS) {
val lastSlashIdx = file.lastIndexOf('/')
process(TETHEALLA_QUEST_PATH_PREFIX + file, file.drop(lastSlashIdx + 1))
}
}
const val TETHEALLA_QUEST_PATH_PREFIX = "/tethealla_v0.143_quests"
val TETHEALLA_QUESTS = listOf(
"/battle/1.qst",
"/battle/2.qst",
"/battle/3.qst",
"/battle/4.qst",
"/battle/5.qst",
"/battle/6.qst",
"/battle/7.qst",
"/battle/8.qst",
"/chl/ep1/1.qst",
"/chl/ep1/2.qst",
"/chl/ep1/3.qst",
"/chl/ep1/4.qst",
"/chl/ep1/5.qst",
"/chl/ep1/6.qst",
"/chl/ep1/7.qst",
"/chl/ep1/8.qst",
"/chl/ep1/9.qst",
"/chl/ep2/21.qst",
"/chl/ep2/22.qst",
"/chl/ep2/23.qst",
"/chl/ep2/24.qst",
"/chl/ep2/25.qst",
"/ep1/event/ma1.qst",
"/ep1/event/ma4-a.qst",
"/ep1/event/ma4-b.qst",
"/ep1/event/ma4-c.qst",
"/ep1/event/princgift.qst",
"/ep1/event/sunset base.qst",
"/ep1/event/whiteday.qst",
"/ep1/ext/en1.qst",
"/ep1/ext/en2.qst",
"/ep1/ext/en3.qst",
"/ep1/ext/en4.qst",
"/ep1/ext/mop-up1.qst",
"/ep1/ext/mop-up2.qst",
"/ep1/ext/mop-up3.qst",
"/ep1/ext/mop-up4.qst",
"/ep1/ext/todays rate.qst",
"/ep1/recovery/fragmentofmemoryen.qst",
"/ep1/recovery/gallon.qst",
"/ep1/recovery/lost havoc vulcan.qst",
"/ep1/recovery/lost heat sword.qst",
"/ep1/recovery/lost ice spinner.qst",
"/ep1/recovery/lost soul blade.qst",
"/ep1/recovery/rappy holiday.qst",
"/ep1/vr/labyrinthe trial.qst",
"/ep1/vr/ttf.qst",
"/ep2/event/beach laughter.qst",
"/ep2/event/christmas.qst",
"/ep2/event/dream messenger.qst",
"/ep2/event/halloween.qst",
"/ep2/event/ma2.qst",
// ma4-a.qst seems corrupt, doesn't work in qedit either.
// "/ep2/event/ma4-a.qst",
"/ep2/event/ma4-b.qst",
"/ep2/event/ma4-c.qst",
"/ep2/event/quest239.qst",
"/ep2/event/singing by the beach.qst",
"/ep2/ext/pw1.qst",
"/ep2/ext/pw2.qst",
"/ep2/ext/pw3.qst",
"/ep2/ext/pw4.qst",
"/ep2/shop/gallon.qst",
"/ep2/tower/east.qst",
"/ep2/tower/west.qst",
"/ep2/vr/reach for the dream.qst",
"/ep2/vr/respectivetomorrow.qst",
"/ep4/event/clarie's deal.qst",
"/ep4/event/login.qst",
"/ep4/event/ma4-a.qst",
"/ep4/event/ma4-b.qst",
"/ep4/event/ma4-c.qst",
"/ep4/event/wildhouse.qst",
"/ep4/ext/newwipe1.qst",
"/ep4/ext/newwipe2.qst",
"/ep4/ext/newwipe3.qst",
"/ep4/ext/newwipe4.qst",
"/ep4/ext/newwipe5.qst",
"/ep4/ext/waroflimit1.qst",
"/ep4/ext/waroflimit2.qst",
"/ep4/ext/waroflimit3.qst",
"/ep4/ext/waroflimit4.qst",
"/ep4/ext/waroflimit5.qst",
"/ep4/shop/itempresent.qst",
"/ep4/shop/quest205.qst",
"/ep4/vr/max3.qst",
"/princ/ep1/1-1.qst",
"/princ/ep1/1-2.qst",
"/princ/ep1/1-3.qst",
"/princ/ep1/2-1.qst",
"/princ/ep1/2-2.qst",
"/princ/ep1/2-3.qst",
"/princ/ep1/2-4.qst",
"/princ/ep1/3-1.qst",
"/princ/ep1/3-2.qst",
"/princ/ep1/3-3.qst",
"/princ/ep1/4-1.qst",
"/princ/ep1/4-2.qst",
"/princ/ep1/4-3.qst",
"/princ/ep1/4-4.qst",
"/princ/ep1/4-5.qst",
"/princ/ep2/quest451.raw",
"/princ/ep2/quest452.raw",
"/princ/ep2/quest453.raw",
"/princ/ep2/quest454.raw",
"/princ/ep2/quest455.raw",
"/princ/ep2/quest456.raw",
"/princ/ep2/quest457.raw",
"/princ/ep2/quest458.raw",
"/princ/ep2/quest459.raw",
"/princ/ep2/quest460.raw",
"/princ/ep2/quest461.raw",
"/princ/ep2/quest462.raw",
"/princ/ep2/quest463.raw",
"/princ/ep2/quest464.raw",
"/princ/ep2/quest465.raw",
"/princ/ep2/quest466.raw",
"/princ/ep2/quest467.raw",
"/princ/ep2/quest468.raw",
"/princ/ep4/9-1.qst",
"/princ/ep4/9-2.qst",
"/princ/ep4/9-3.qst",
"/princ/ep4/9-4.qst",
"/princ/ep4/9-5.qst",
"/princ/ep4/9-6.qst",
"/princ/ep4/9-7.qst",
"/princ/ep4/9-8.qst",
"/princ/ep4/pod.qst",
"/solo/ep1/01.qst",
"/solo/ep1/02.qst",
"/solo/ep1/03.qst",
"/solo/ep1/04.qst",
"/solo/ep1/05.qst",
"/solo/ep1/06.qst",
"/solo/ep1/07.qst",
"/solo/ep1/08.qst",
"/solo/ep1/09.qst",
"/solo/ep1/10.qst",
"/solo/ep1/11.qst",
"/solo/ep1/12.qst",
"/solo/ep1/13.qst",
"/solo/ep1/14.qst",
"/solo/ep1/15.qst",
"/solo/ep1/16.qst",
"/solo/ep1/17.qst",
"/solo/ep1/18.qst",
"/solo/ep1/19.qst",
"/solo/ep1/20.qst",
"/solo/ep1/21.qst",
"/solo/ep1/22.qst",
"/solo/ep1/23.qst",
"/solo/ep1/24.qst",
"/solo/ep1/25.qst",
"/solo/ep1/side/26.qst",
"/solo/ep1/side/goodluck.qst",
"/solo/ep1/side/quest035.qst",
"/solo/ep1/side/quest073.qst",
"/solo/ep2/01.qst",
"/solo/ep4/01-blackpaper.qst",
"/solo/ep4/02-pioneer spirit.qst",
"/solo/ep4/03-Warrior Pride.qst",
"/solo/ep4/04-Restless Lion.qst",
"/solo/ep4/blackpaper2.qst",
"/solo/ep4/wilderending.qst",
)

View File

@ -0,0 +1,3 @@
battle\
Battle
Two or more$players fight$it out...$ $Who is the$best hunter?

View File

@ -0,0 +1,8 @@
1.qst
2.qst
3.qst
4.qst
5.qst
6.qst
7.qst
8.qst

View File

@ -0,0 +1,7 @@
ma1.qst
ma4-a.qst
ma4-b.qst
ma4-c.qst
princgift.qst
sunset base.qst
whiteday.qst

View File

@ -0,0 +1,9 @@
en1.qst
en2.qst
en3.qst
en4.qst
mop-up1.qst
mop-up2.qst
mop-up3.qst
mop-up4.qst
todays rate.qst

View File

@ -0,0 +1,7 @@
fragmentofmemoryen.qst
gallon.qst
lost heat sword.qst
lost ice spinner.qst
lost soul blade.qst
lost havoc vulcan.qst
rappy holiday.qst

View File

@ -0,0 +1,2 @@
labyrinthe trial.qst
ttf.qst

View File

@ -0,0 +1,3 @@
princ\ep1\
Government
Urgent missions$from the$Principal!

View File

@ -0,0 +1,6 @@
solo\ep1\
Main Story
Experience the$main story of$Phantasy Star$Online!
solo\ep1\side\
Side Story
Uncover a$little more$about what's$going on...

View File

@ -0,0 +1,12 @@
ep1\event\
Event
Quests made$for special$events.
ep1\ext\
Extermination
Quests where$you need to$kill monsters.
ep1\recovery\
Retrieval
Quests where$you need to$retrieve an$item.
ep1\vr\
VR
Battle enemies$in a virtual$room!

View File

@ -0,0 +1,9 @@
christmas.qst
dream messenger.qst
quest239.qst
halloween.qst
ma2.qst
ma4-a.qst
ma4-b.qst
ma4-c.qst
singing by the beach.qst

View File

@ -0,0 +1,4 @@
pw1.qst
pw2.qst
pw3.qst
pw4.qst

View File

@ -0,0 +1 @@
gallon.qst

View File

@ -0,0 +1,2 @@
east.qst
west.qst

View File

@ -0,0 +1,2 @@
reach for the dream.qst
respectivetomorrow.qst

View File

@ -0,0 +1,3 @@
princ\ep2\
Lab
Urgent missions$from the Lab!

View File

@ -0,0 +1,3 @@
solo\ep2\
Side Story
Uncover a$little more$about what's$going on...

View File

@ -0,0 +1,15 @@
ep2\event\
Event
Quests made$for special$events.
ep2\ext\
Extermination
Quests where$you need to$kill monsters.
ep2\shop\
Shop
Time for$shopping!
ep2\tower\
Tower
Run through$the tower of$the dome.
ep2\vr\
VR
Battle enemies$in a virtual$room!

Some files were not shown because too many files have changed in this diff Show More