From ade67282c81b68f8f305e6fed9eb160a6700b709 Mon Sep 17 00:00:00 2001 From: boubou19 Date: Fri, 14 Aug 2026 00:58:43 +0200 Subject: [PATCH 01/14] test(integration): lock wire geometry output --- dependencies.gradle | 2 + .../integration/gate-wire-golden-v1.txt | 1 + .../integration/GateWireGoldenTest.scala | 174 ++++++++++++++++++ 3 files changed, 177 insertions(+) create mode 100644 src/test/resources/mrtjp/projectred/integration/gate-wire-golden-v1.txt create mode 100644 src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala diff --git a/dependencies.gradle b/dependencies.gradle index 6b29b93ee..4e4ce68d6 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -11,4 +11,6 @@ dependencies { compileOnly("curse.maven:cofh-core-69162:2388751") compileOnly("curse.maven:computercraft-67504:2269339") // https://www.curseforge.com/minecraft/mc-mods/computercraft/files/2269339 compileOnly files("dependencies/ColoredLightsCore-1.3.7.39.jar") + + testImplementation("junit:junit:4.13.2") } diff --git a/src/test/resources/mrtjp/projectred/integration/gate-wire-golden-v1.txt b/src/test/resources/mrtjp/projectred/integration/gate-wire-golden-v1.txt new file mode 100644 index 000000000..84508cc55 --- /dev/null +++ b/src/test/resources/mrtjp/projectred/integration/gate-wire-golden-v1.txt @@ -0,0 +1 @@ +8ddd02a1a06ff4129d6d82b600700c1667036824861b0b2ba7efc91bf0adc54b diff --git a/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala b/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala new file mode 100644 index 000000000..021604e3a --- /dev/null +++ b/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala @@ -0,0 +1,174 @@ +package mrtjp.projectred.integration + +import java.io.{ByteArrayOutputStream, DataOutputStream, File} +import java.nio.charset.StandardCharsets +import java.nio.file.Files +import java.security.MessageDigest +import javax.imageio.ImageIO + +import codechicken.lib.colour.{Colour, ColourARGB} +import codechicken.lib.render.CCModel +import codechicken.lib.vec.Rectangle4i +import org.junit.Assert.{assertEquals, assertNotNull} +import org.junit.Test + +import scala.io.Source + +class GateWireGoldenTest { + + @Test + def wireGeometryMatchesGoldenOutput(): Unit = { + val actual = GateWireGoldenTest.maskNames.map(characterize) + val actualText = (GateWireGoldenTest.header +: actual).mkString("\n") + "\n" + val actualDigest = sha256(actualText.getBytes(StandardCharsets.UTF_8)) + val expectedDigest = readResource(GateWireGoldenTest.goldenPath).trim + + if (actualDigest != expectedDigest) { + val report = new File("build/reports/wire-golden/actual-v1.txt") + report.getParentFile.mkdirs() + Files.write(report.toPath, actualText.getBytes(StandardCharsets.UTF_8)) + } + + assertEquals( + "Wire geometry changed; see build/reports/wire-golden/actual-v1.txt", + expectedDigest, + actualDigest + ) + } + + private def characterize(name: String): String = { + val data = loadMask(name) + val rectangles = TWireModel.rectangulate(data) + val model = WireModel3D.generateModel(data) + + Seq( + name, + rectangles.length.toString, + model.verts.length.toString, + digestRectangles(rectangles), + digestModel(model) + ).mkString(" ") + } + + private def loadMask(name: String): Array[Colour] = { + val path = GateWireGoldenTest.maskPath + name + ".png" + val stream = getClass.getResourceAsStream(path) + assertNotNull("Missing wire mask " + path, stream) + + val image = + try ImageIO.read(stream) + finally stream.close() + assertNotNull("Invalid wire mask " + path, image) + assertEquals("Unexpected width for " + name, 32, image.getWidth) + assertEquals("Unexpected height for " + name, 32, image.getHeight) + + image + .getRGB(0, 0, image.getWidth, image.getHeight, null, 0, image.getWidth) + .map(new ColourARGB(_): Colour) + } + + private def digestRectangles(rectangles: Seq[Rectangle4i]): String = digest { + out => + out.writeInt(rectangles.length) + rectangles.foreach { rectangle => + out.writeInt(rectangle.x) + out.writeInt(rectangle.y) + out.writeInt(rectangle.w) + out.writeInt(rectangle.h) + } + } + + private def digestModel(model: CCModel): String = digest { out => + val normals = model.normals() + out.writeInt(model.vertexMode) + out.writeInt(model.vp) + out.writeInt(model.verts.length) + model.verts.indices.foreach { i => + val vertex = model.verts(i) + val normal = normals(i) + writeDouble(out, vertex.vec.x) + writeDouble(out, vertex.vec.y) + writeDouble(out, vertex.vec.z) + writeDouble(out, vertex.uv.u) + writeDouble(out, vertex.uv.v) + out.writeInt(vertex.uv.tex) + writeDouble(out, normal.x) + writeDouble(out, normal.y) + writeDouble(out, normal.z) + } + } + + private def digest(write: DataOutputStream => Unit): String = { + val bytes = new ByteArrayOutputStream + val out = new DataOutputStream(bytes) + write(out) + out.close() + sha256(bytes.toByteArray) + } + + private def sha256(bytes: Array[Byte]): String = + MessageDigest + .getInstance("SHA-256") + .digest(bytes) + .map("%02x".format(_)) + .mkString + + private def writeDouble(out: DataOutputStream, value: Double): Unit = { + require(!value.isNaN && !value.isInfinity, "Non-finite model value") + out.writeLong(Math.round((if (value == 0) 0 else value) * 1000000000L)) + } + + private def readResource(path: String): String = { + val stream = getClass.getResourceAsStream(path) + assertNotNull("Missing golden output " + path, stream) + val source = Source.fromInputStream(stream, "UTF-8") + try source.mkString + finally source.close() + } +} + +object GateWireGoldenTest { + val goldenPath = "/mrtjp/projectred/integration/gate-wire-golden-v1.txt" + val maskPath = "/assets/projectred/textures/blocks/integration/surface/" + val header = "# gate-wire-golden-v1 scale=1e-9 masks=120" + + val maskNames = Seq( + "OR" -> 4, + "NOR" -> 4, + "NOT" -> 4, + "AND" -> 4, + "NAND" -> 4, + "XOR" -> 4, + "XNOR" -> 5, + "BUFFER" -> 4, + "MULTIPLEXER" -> 6, + "PULSE" -> 3, + "REPEATER" -> 2, + "RAND" -> 7, + "RSLATCH" -> 2, + "RSLATCH2" -> 4, + "TOGLATCH" -> 2, + "TRANSLATCH" -> 5, + "LIGHTSENSOR" -> 1, + "RAINSENSOR" -> 1, + "TIME" -> 3, + "COUNT" -> 2, + "STATECELL" -> 5, + "SYNC" -> 6, + "BUSXCVR" -> 2, + "COMPARATOR" -> 4, + "BUSRAND1" -> 2, + "BUSRAND2" -> 2, + "BUSCONV" -> 3, + "BUSINPUT" -> 1, + "INVCELL" -> 1, + "BUFFCELL" -> 2, + "ANDCELL" -> 2, + "STACKLATCH" -> 5, + "DECRAND" -> 6, + "IC1" -> 4, + "IC2" -> 4 + ).flatMap { case (name, count) => (0 until count).map(name + "-" + _) } + + require(maskNames.length == 120) +} From 1bc1c422ae515cae6f827a7b16a2a41ed09b3661 Mon Sep 17 00:00:00 2001 From: boubou19 Date: Fri, 14 Aug 2026 01:01:31 +0200 Subject: [PATCH 02/14] test(integration): cover wire model baking --- .../projectred/integration/components.scala | 97 ++++++++++--------- .../integration/gate-wire-baking-v1.txt | 1 + .../integration/gate-wire-geometry-v1.txt | 1 + .../integration/gate-wire-golden-v1.txt | 1 - .../integration/GateWireGoldenTest.scala | 85 ++++++++++++---- 5 files changed, 120 insertions(+), 65 deletions(-) create mode 100644 src/test/resources/mrtjp/projectred/integration/gate-wire-baking-v1.txt create mode 100644 src/test/resources/mrtjp/projectred/integration/gate-wire-geometry-v1.txt delete mode 100644 src/test/resources/mrtjp/projectred/integration/gate-wire-golden-v1.txt diff --git a/src/main/scala/mrtjp/projectred/integration/components.scala b/src/main/scala/mrtjp/projectred/integration/components.scala index 87cc16096..41b8177e7 100644 --- a/src/main/scala/mrtjp/projectred/integration/components.scala +++ b/src/main/scala/mrtjp/projectred/integration/components.scala @@ -23,6 +23,55 @@ import net.minecraft.util.{IIcon, ResourceLocation} import scala.collection.JavaConversions._ import scala.util.control.Breaks +private[integration] object ComponentModelBakery { + val orientPrecomputed = (0 until 48).map(orientT).toArray + val bundledCablePrecomputed = (0 until 48) + .map((orient: Int) => { + val side = orient % 24 >> 2 + val r = orient & 3 + val reflect = orient >= 24 + val rotate = (r + WireModelGen.reorientSide(side)) % 4 >= 2 + + var t: Transformation = new RedundantTransformation + if (reflect) t = t.`with`(new Scale(-1, 0, 1)) + if (rotate) t = t.`with`(Rotation.quarterRotations(2)) + t + }) + .toArray + val redundantUVTransformation = new UVTransformationList() + + def orientT(orient: Int) = { + var t = Rotation.sideOrientation(orient % 24 >> 2, orient & 3) + if (orient >= 24) t = new Scale(-1, 1, 1).`with`(t) + t.at(Vector3.center) + } + + def dynamicT(orient: Int) = + if (orient == 0) new RedundantTransformation + else new Scale(-1, 1, 1).at(Vector3.center) + + def bakeCopy(base: CCModel, orient: Int) = { + val m = base.copy + if (orient >= 24) reverseFacing(m) + m.apply(orientT(orient)).computeLighting(LightModel.standardLightModel) + m + } + + def bakeDynamic(base: CCModel) = Array(base.copy, reverseFacing(base.copy)) + + private def reverseFacing(m: CCModel) = { + for (i <- 0 until m.verts.length by 4) { + val vtmp = m.verts(i + 1) + val ntmp = m.normals()(i + 1) + m.verts(i + 1) = m.verts(i + 3) + m.normals()(i + 1) = m.normals()(i + 3) + m.verts(i + 3) = vtmp + m.normals()(i + 3) = ntmp + } + m + } +} + object ComponentStore { val base = loadBase("base") val lightChip = loadModel("chip") @@ -93,22 +142,6 @@ object ComponentStore { var icChipIconOff: IIcon = null var icHousingIcon: IIcon = null - val orientPrecomputed = (0 until 48).map(orientT).toArray - val bundledCablePrecomputed = (0 until 48) - .map((orient: Int) => { - val side = orient % 24 >> 2 - val r = orient & 3 - val reflect = orient >= 24 - val rotate = (r + WireModelGen.reorientSide(side)) % 4 >= 2 - - var t: Transformation = new RedundantTransformation - if (reflect) t = t.`with`(new Scale(-1, 0, 1)) - if (rotate) t = t.`with`(Rotation.quarterRotations(2)) - t - }) - .toArray - val redundantUVTransformation = new UVTransformationList() - def registerIcons(reg: IIconRegister) { val baseTex = "projectred:integration/" def register(path: String) = reg.registerIcon(baseTex + path) @@ -198,37 +231,6 @@ object ComponentStore { m } - def orientT(orient: Int) = { - var t = Rotation.sideOrientation(orient % 24 >> 2, orient & 3) - if (orient >= 24) t = new Scale(-1, 1, 1).`with`(t) - t.at(Vector3.center) - } - - def dynamicT(orient: Int) = - if (orient == 0) new RedundantTransformation - else new Scale(-1, 1, 1).at(Vector3.center) - - def bakeCopy(base: CCModel, orient: Int) = { - val m = base.copy - if (orient >= 24) reverseFacing(m) - m.apply(orientT(orient)).computeLighting(LightModel.standardLightModel) - m - } - - def bakeDynamic(base: CCModel) = Array(base.copy, reverseFacing(base.copy)) - - private def reverseFacing(m: CCModel) = { - for (i <- 0 until m.verts.length by 4) { - val vtmp = m.verts(i + 1) - val ntmp = m.normals()(i + 1) - m.verts(i + 1) = m.verts(i + 3) - m.normals()(i + 1) = m.normals()(i + 3) - m.verts(i + 3) = vtmp - m.normals()(i + 3) = ntmp - } - m - } - def generateWireModels(name: String, count: Int) = { val xs = Seq.newBuilder[TWireModel] for (i <- 0 until count) xs += generateWireModel(name + "-" + i) @@ -248,6 +250,7 @@ object ComponentStore { } import mrtjp.projectred.integration.ComponentStore._ +import mrtjp.projectred.integration.ComponentModelBakery._ abstract class ComponentModel { def renderModel(t: Transformation, orient: Int) diff --git a/src/test/resources/mrtjp/projectred/integration/gate-wire-baking-v1.txt b/src/test/resources/mrtjp/projectred/integration/gate-wire-baking-v1.txt new file mode 100644 index 000000000..80443351e --- /dev/null +++ b/src/test/resources/mrtjp/projectred/integration/gate-wire-baking-v1.txt @@ -0,0 +1 @@ +a9e81b7cda819306e288b45f42a1c0445f2cccebdfc7a37e981ddde80c8c9c29 diff --git a/src/test/resources/mrtjp/projectred/integration/gate-wire-geometry-v1.txt b/src/test/resources/mrtjp/projectred/integration/gate-wire-geometry-v1.txt new file mode 100644 index 000000000..707662118 --- /dev/null +++ b/src/test/resources/mrtjp/projectred/integration/gate-wire-geometry-v1.txt @@ -0,0 +1 @@ +7a39d31fb1365c57d285357a344e13c22598c477459d6f32270c63cb4d017b9c diff --git a/src/test/resources/mrtjp/projectred/integration/gate-wire-golden-v1.txt b/src/test/resources/mrtjp/projectred/integration/gate-wire-golden-v1.txt deleted file mode 100644 index 84508cc55..000000000 --- a/src/test/resources/mrtjp/projectred/integration/gate-wire-golden-v1.txt +++ /dev/null @@ -1 +0,0 @@ -8ddd02a1a06ff4129d6d82b600700c1667036824861b0b2ba7efc91bf0adc54b diff --git a/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala b/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala index 021604e3a..1c1c3caa5 100644 --- a/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala +++ b/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala @@ -9,7 +9,7 @@ import javax.imageio.ImageIO import codechicken.lib.colour.{Colour, ColourARGB} import codechicken.lib.render.CCModel import codechicken.lib.vec.Rectangle4i -import org.junit.Assert.{assertEquals, assertNotNull} +import org.junit.Assert.{assertEquals, assertNotNull, assertNotSame} import org.junit.Test import scala.io.Source @@ -19,21 +19,29 @@ class GateWireGoldenTest { @Test def wireGeometryMatchesGoldenOutput(): Unit = { val actual = GateWireGoldenTest.maskNames.map(characterize) - val actualText = (GateWireGoldenTest.header +: actual).mkString("\n") + "\n" - val actualDigest = sha256(actualText.getBytes(StandardCharsets.UTF_8)) - val expectedDigest = readResource(GateWireGoldenTest.goldenPath).trim + assertGolden("geometry", GateWireGoldenTest.header, actual) + } - if (actualDigest != expectedDigest) { - val report = new File("build/reports/wire-golden/actual-v1.txt") - report.getParentFile.mkdirs() - Files.write(report.toPath, actualText.getBytes(StandardCharsets.UTF_8)) - } + @Test + def wireBakingMatchesGoldenOutput(): Unit = { + val actual = GateWireGoldenTest.maskNames.map(characterizeBaking) + assertGolden("baking", GateWireGoldenTest.bakingHeader, actual) + } - assertEquals( - "Wire geometry changed; see build/reports/wire-golden/actual-v1.txt", - expectedDigest, - actualDigest - ) + @Test + def bakedModelsDoNotShareMutableGeometry(): Unit = { + val base = WireModel3D.generateModel(loadMask("OR-0")) + val pair = ComponentModelBakery.bakeDynamic(base) + + assertNotSame(pair(0), pair(1)) + assertNotSame(pair(0).verts(0), pair(1).verts(0)) + assertNotSame(pair(0).verts(0).vec, pair(1).verts(0).vec) + assertNotSame(pair(0).verts(0).uv, pair(1).verts(0).uv) + assertNotSame(pair(0).normals()(0), pair(1).normals()(0)) + + val originalX = pair(0).verts(0).vec.x + pair(1).verts(0).vec.x += 1 + assertEquals(originalX, pair(0).verts(0).vec.x, 0) } private def characterize(name: String): String = { @@ -50,6 +58,20 @@ class GateWireGoldenTest { ).mkString(" ") } + private def characterizeBaking(name: String): String = { + val modelPair = ComponentModelBakery.bakeDynamic( + WireModel3D.generateModel(loadMask(name)) + ) + val orientedDigest = digest { out => + for (orient <- 0 until 48) { + val model = modelPair(if (orient < 24) 0 else 1).copy + model.apply(ComponentModelBakery.orientPrecomputed(orient)) + writeModel(out, model) + } + } + name + " " + modelPair(0).verts.length + " " + orientedDigest + } + private def loadMask(name: String): Array[Colour] = { val path = GateWireGoldenTest.maskPath + name + ".png" val stream = getClass.getResourceAsStream(path) @@ -78,7 +100,9 @@ class GateWireGoldenTest { } } - private def digestModel(model: CCModel): String = digest { out => + private def digestModel(model: CCModel): String = digest(writeModel(_, model)) + + private def writeModel(out: DataOutputStream, model: CCModel): Unit = { val normals = model.normals() out.writeInt(model.vertexMode) out.writeInt(model.vp) @@ -125,12 +149,39 @@ class GateWireGoldenTest { try source.mkString finally source.close() } + + private def assertGolden( + kind: String, + header: String, + actual: Seq[String] + ): Unit = { + val actualText = (header +: actual).mkString("\n") + "\n" + val actualDigest = sha256(actualText.getBytes(StandardCharsets.UTF_8)) + val goldenPath = + "/mrtjp/projectred/integration/gate-wire-" + kind + "-v1.txt" + val expectedDigest = readResource(goldenPath).trim + + if (actualDigest != expectedDigest) { + val report = new File( + "build/reports/wire-golden/actual-" + kind + "-v1.txt" + ) + report.getParentFile.mkdirs() + Files.write(report.toPath, actualText.getBytes(StandardCharsets.UTF_8)) + } + + assertEquals( + "Wire " + kind + " changed; see build/reports/wire-golden/actual-" + kind + "-v1.txt", + expectedDigest, + actualDigest + ) + } } object GateWireGoldenTest { - val goldenPath = "/mrtjp/projectred/integration/gate-wire-golden-v1.txt" val maskPath = "/assets/projectred/textures/blocks/integration/surface/" - val header = "# gate-wire-golden-v1 scale=1e-9 masks=120" + val header = "# gate-wire-geometry-v1 scale=1e-9 masks=120" + val bakingHeader = + "# gate-wire-baking-v1 scale=1e-9 masks=120 orientations=48" val maskNames = Seq( "OR" -> 4, From 32c5b7b43b9af86d15fb9daa97014c6cb0490663 Mon Sep 17 00:00:00 2001 From: boubou19 Date: Fri, 14 Aug 2026 01:06:05 +0200 Subject: [PATCH 03/14] test(integration): lock 2D wire output --- .../projectred/integration/components.scala | 72 +++++++++++-------- .../integration/gate-wire-2d-v1.txt | 1 + .../integration/GateWireGoldenTest.scala | 43 +++++++++-- 3 files changed, 84 insertions(+), 32 deletions(-) create mode 100644 src/test/resources/mrtjp/projectred/integration/gate-wire-2d-v1.txt diff --git a/src/main/scala/mrtjp/projectred/integration/components.scala b/src/main/scala/mrtjp/projectred/integration/components.scala index 41b8177e7..172766520 100644 --- a/src/main/scala/mrtjp/projectred/integration/components.scala +++ b/src/main/scala/mrtjp/projectred/integration/components.scala @@ -402,6 +402,43 @@ object TWireModel { if (border.y + border.h >= 32) border.h -= border.y + border.h - 32 border } + + private[integration] def buildTexMap(wireRectangles: Seq[Rectangle4i]) = { + val texMap = new Array[Int](1024) + for (rect <- wireRectangles) { + fillMask(texMap, rect, 2) + fillMask(texMap, border(rect), 1) + } + texMap + } + + private[integration] def buildTexture( + texMap: Array[Int], + wireData: Array[Array[Colour]], + tex: Int + ) = { + val pSize = Math.sqrt(wireData(0).length).asInstanceOf[Int] + val size = Math.max(32, pSize) + val relM = size / 32 + val relP = size / pSize + val imageData = new Array[Int](size * size) + + for (i <- 0 until imageData.length) { + val x = i % size + val y = i / size + val t = texMap(y / relM * 32 + x / relM) + if (t != 0) + imageData(i) = + wireData(if (t == 1) 0 else tex)(y / relP * pSize + x / relP).argb() + } + imageData + } + + private def fillMask(map: Array[Int], r: Rectangle4i, v: Int) { + for (i <- r.x until r.x + r.w) + for (j <- r.y until r.y + r.h) + if (map(j * 32 + i) < v) map(j * 32 + i) = v + } } class WireModel3D(data: Array[Colour]) @@ -464,43 +501,22 @@ class WireModel2D(data: Array[Colour]) extends ComponentModel with TWireModel { override def registerIcons(reg: IIconRegister) { val wireRectangles = TWireModel.rectangulate(data) + val texMap = TWireModel.buildTexMap(wireRectangles) icons = new Array[TextureSpecial](wireData.length) for (tex <- 0 until icons.length) { - val texMap = new Array[Int](1024) - for (rect <- wireRectangles) { - fillMask(texMap, rect, 2) - fillMask(texMap, TWireModel.border(rect), 1) - } - - val pSize = Math.sqrt(wireData(0).length).asInstanceOf[Int] - val size = Math.max(32, pSize) - val relM = size / 32 - val relP = size / pSize - - val imageData = new Array[Int](size * size) - for (i <- 0 until imageData.length) { - val x = i % size - val y = i / size - val t = texMap(y / relM * 32 + x / relM) - if (t != 0) - imageData(i) = - wireData(if (t == 1) 0 else tex)(y / relP * pSize + x / relP).argb() - } - icons(tex) = TextureUtils .getTextureSpecial( reg, "projectred:integration/wire2d_" + iconIndex + "_" + tex ) - .addTexture(new TextureDataHolder(imageData, size)) + .addTexture( + new TextureDataHolder( + TWireModel.buildTexture(texMap, wireData, tex), + Math.max(32, Math.sqrt(wireData(0).length).asInstanceOf[Int]) + ) + ) } } - - def fillMask(map: Array[Int], r: Rectangle4i, v: Int) { - for (i <- r.x until r.x + r.w) - for (j <- r.y until r.y + r.h) - if (map(j * 32 + i) < v) map(j * 32 + i) = v - } } object WireModel2D { diff --git a/src/test/resources/mrtjp/projectred/integration/gate-wire-2d-v1.txt b/src/test/resources/mrtjp/projectred/integration/gate-wire-2d-v1.txt new file mode 100644 index 000000000..ca656e879 --- /dev/null +++ b/src/test/resources/mrtjp/projectred/integration/gate-wire-2d-v1.txt @@ -0,0 +1 @@ +1fbc12977134f4c484cab8351a53f37a7ae58ec9cc7ab3c5dec603afef316477 diff --git a/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala b/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala index 1c1c3caa5..69fa32a64 100644 --- a/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala +++ b/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala @@ -28,6 +28,18 @@ class GateWireGoldenTest { assertGolden("baking", GateWireGoldenTest.bakingHeader, actual) } + @Test + def wire2DOutputMatchesGoldenOutput(): Unit = { + val wireData = GateWireGoldenTest.materialNames.map(loadImage).toArray + val planeDigest = digest { out => + WireModel2D.models.foreach(writeModel(out, _)) + } + val actual = ("plane " + planeDigest) +: GateWireGoldenTest.maskNames.map { + name => characterize2D(name, wireData) + } + assertGolden("2d", GateWireGoldenTest.twoDHeader, actual) + } + @Test def bakedModelsDoNotShareMutableGeometry(): Unit = { val base = WireModel3D.generateModel(loadMask("OR-0")) @@ -72,7 +84,24 @@ class GateWireGoldenTest { name + " " + modelPair(0).verts.length + " " + orientedDigest } + private def characterize2D(name: String, wireData: Array[Array[Colour]]) = { + val texMap = TWireModel.buildTexMap(TWireModel.rectangulate(loadMask(name))) + val textures = wireData.indices.map { tex => + digestInts(TWireModel.buildTexture(texMap, wireData, tex)) + } + (name +: digestInts(texMap) +: textures).mkString(" ") + } + private def loadMask(name: String): Array[Colour] = { + val (width, height, data) = loadImageData(name) + assertEquals("Unexpected width for " + name, 32, width) + assertEquals("Unexpected height for " + name, 32, height) + data + } + + private def loadImage(name: String): Array[Colour] = loadImageData(name)._3 + + private def loadImageData(name: String): (Int, Int, Array[Colour]) = { val path = GateWireGoldenTest.maskPath + name + ".png" val stream = getClass.getResourceAsStream(path) assertNotNull("Missing wire mask " + path, stream) @@ -81,12 +110,10 @@ class GateWireGoldenTest { try ImageIO.read(stream) finally stream.close() assertNotNull("Invalid wire mask " + path, image) - assertEquals("Unexpected width for " + name, 32, image.getWidth) - assertEquals("Unexpected height for " + name, 32, image.getHeight) - - image + val data = image .getRGB(0, 0, image.getWidth, image.getHeight, null, 0, image.getWidth) .map(new ColourARGB(_): Colour) + (image.getWidth, image.getHeight, data) } private def digestRectangles(rectangles: Seq[Rectangle4i]): String = digest { @@ -102,6 +129,11 @@ class GateWireGoldenTest { private def digestModel(model: CCModel): String = digest(writeModel(_, model)) + private def digestInts(values: Array[Int]): String = digest { out => + out.writeInt(values.length) + values.foreach(out.writeInt) + } + private def writeModel(out: DataOutputStream, model: CCModel): Unit = { val normals = model.normals() out.writeInt(model.vertexMode) @@ -182,6 +214,9 @@ object GateWireGoldenTest { val header = "# gate-wire-geometry-v1 scale=1e-9 masks=120" val bakingHeader = "# gate-wire-baking-v1 scale=1e-9 masks=120 orientations=48" + val twoDHeader = + "# gate-wire-2d-v1 scale=1e-9 masks=120 textures=3 orientations=48" + val materialNames = Seq("bordermatte", "wirematte-OFF", "wirematte-ON") val maskNames = Seq( "OR" -> 4, From ff07137b710f44c9864ef60e00e397ffa5dc8239 Mon Sep 17 00:00:00 2001 From: boubou19 Date: Fri, 14 Aug 2026 01:13:25 +0200 Subject: [PATCH 04/14] perf(integration): streamline wire merging --- .../projectred/integration/components.scala | 71 +++++++++++-------- 1 file changed, 41 insertions(+), 30 deletions(-) diff --git a/src/main/scala/mrtjp/projectred/integration/components.scala b/src/main/scala/mrtjp/projectred/integration/components.scala index 172766520..983cee53e 100644 --- a/src/main/scala/mrtjp/projectred/integration/components.scala +++ b/src/main/scala/mrtjp/projectred/integration/components.scala @@ -21,7 +21,6 @@ import net.minecraft.client.renderer.texture.IIconRegister import net.minecraft.util.{IIcon, ResourceLocation} import scala.collection.JavaConversions._ -import scala.util.control.Breaks private[integration] object ComponentModelBakery { val orientPrecomputed = (0 until 48).map(orientT).toArray @@ -339,46 +338,58 @@ object TWireModel { def rectangulate(data: Array[Colour]) = { val wireCorners = new Array[Boolean](1024) - for (y <- 0 to 30) for (x <- 0 to 30) Breaks.breakable { - if (data(y * 32 + x).rgba != -1) Breaks.break() - if (overlap(wireCorners, x, y)) Breaks.break() - if (!segment2x2(data, x, y)) - throw new RuntimeException( - "Wire segment not 2x2 at (" + x + ", " + y + ")" - ) - - wireCorners(y * 32 + x) = true + var y = 0 + while (y <= 30) { + var x = 0 + while (x <= 30) { + if (data(y * 32 + x).rgba == -1 && !overlap(wireCorners, x, y)) { + if (!segment2x2(data, x, y)) + throw new RuntimeException( + "Wire segment not 2x2 at (" + x + ", " + y + ")" + ) + + wireCorners(y * 32 + x) = true + } + x += 1 + } + y += 1 } - var wireRectangles = Seq.newBuilder[Rectangle4i] - for (i <- 0 until 1024) if (wireCorners(i)) { - val rect = new Rectangle4i(i % 32, i / 32, 0, 0) - var x = rect.x + 2 - while (x < 30 && wireCorners(rect.y * 32 + x)) x += 2 - rect.w = x - rect.x - - var y = rect.y + 2 - Breaks.breakable { - while (y < 30) { - var advance = true + val wireRectangles = Seq.newBuilder[Rectangle4i] + var i = 0 + while (i < 1024) { + if (wireCorners(i)) { + val rect = new Rectangle4i(i % 32, i / 32, 0, 0) + var x = rect.x + 2 + while (x < 30 && wireCorners(rect.y * 32 + x)) x += 2 + rect.w = x - rect.x + + y = rect.y + 2 + var advance = true + while (y < 30 && advance) { var dx = rect.x while (dx < rect.x + rect.w && advance) { if (!wireCorners(y * 32 + dx)) advance = false dx += 2 } - if (!advance) Breaks.break() - - y += 2 + if (advance) y += 2 } - } - rect.h = y - rect.y + rect.h = y - rect.y - for (dy <- rect.y until rect.y + rect.h by 2) - for (dx <- rect.x until rect.x + rect.w by 2) - wireCorners(dy * 32 + dx) = false + var dy = rect.y + while (dy < rect.y + rect.h) { + var dx = rect.x + while (dx < rect.x + rect.w) { + wireCorners(dy * 32 + dx) = false + dx += 2 + } + dy += 2 + } - wireRectangles += rect + wireRectangles += rect + } + i += 1 } wireRectangles.result() From ef69f008d257734e4056a30352a9d16197ab000e Mon Sep 17 00:00:00 2001 From: boubou19 Date: Fri, 14 Aug 2026 01:14:38 +0200 Subject: [PATCH 05/14] perf(integration): use primitive wire masks --- .../projectred/integration/components.scala | 18 ++++++------- .../integration/GateWireGoldenTest.scala | 25 +++++++++++-------- 2 files changed, 23 insertions(+), 20 deletions(-) diff --git a/src/main/scala/mrtjp/projectred/integration/components.scala b/src/main/scala/mrtjp/projectred/integration/components.scala index 983cee53e..544edda7e 100644 --- a/src/main/scala/mrtjp/projectred/integration/components.scala +++ b/src/main/scala/mrtjp/projectred/integration/components.scala @@ -237,7 +237,7 @@ object ComponentStore { } def generateWireModel(name: String) = { - val data = TextureUtils.loadTextureColours( + val data = TextureUtils.loadTextureData( new ResourceLocation( "projectred:textures/blocks/integration/surface/" + name + ".png" ) @@ -335,14 +335,14 @@ trait TWireModel extends ComponentModel { } object TWireModel { - def rectangulate(data: Array[Colour]) = { + def rectangulate(data: Array[Int]) = { val wireCorners = new Array[Boolean](1024) var y = 0 while (y <= 30) { var x = 0 while (x <= 30) { - if (data(y * 32 + x).rgba == -1 && !overlap(wireCorners, x, y)) { + if (data(y * 32 + x) == -1 && !overlap(wireCorners, x, y)) { if (!segment2x2(data, x, y)) throw new RuntimeException( "Wire segment not 2x2 at (" + x + ", " + y + ")" @@ -400,10 +400,10 @@ object TWireModel { (y - 1) * 32 + x )) || (y > 0 && wireCorners((y - 1) * 32 + x - 1)) - def segment2x2(data: Array[Colour], x: Int, y: Int) = - data(y * 32 + x + 1).rgba == -1 && data( + def segment2x2(data: Array[Int], x: Int, y: Int) = + data(y * 32 + x + 1) == -1 && data( (y + 1) * 32 + x - ).rgba == -1 && data((y + 1) * 32 + x + 1).rgba == -1 + ) == -1 && data((y + 1) * 32 + x + 1) == -1 def border(wire: Rectangle4i) = { val border = new Rectangle4i(wire.x - 2, wire.y - 2, wire.w + 4, wire.h + 4) @@ -452,7 +452,7 @@ object TWireModel { } } -class WireModel3D(data: Array[Colour]) +class WireModel3D(data: Array[Int]) extends SingleComponentModel(WireModel3D.generateModel(data)) with TWireModel { override def getUVT = @@ -462,7 +462,7 @@ class WireModel3D(data: Array[Colour]) } object WireModel3D { - def generateModel(data: Array[Colour]) = { + def generateModel(data: Array[Int]) = { val wireRectangles = TWireModel.rectangulate(data) val model = CCModel.quadModel(wireRectangles.length * 40) var i = 0 @@ -497,7 +497,7 @@ object WireModel3D { } } -class WireModel2D(data: Array[Colour]) extends ComponentModel with TWireModel { +class WireModel2D(data: Array[Int]) extends ComponentModel with TWireModel { var icons: Array[TextureSpecial] = _ private val iconIndex = WireModel2D.claimIdx() diff --git a/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala b/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala index 69fa32a64..39d0ebc6c 100644 --- a/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala +++ b/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala @@ -1,6 +1,7 @@ package mrtjp.projectred.integration import java.io.{ByteArrayOutputStream, DataOutputStream, File} +import java.awt.image.BufferedImage import java.nio.charset.StandardCharsets import java.nio.file.Files import java.security.MessageDigest @@ -92,16 +93,21 @@ class GateWireGoldenTest { (name +: digestInts(texMap) +: textures).mkString(" ") } - private def loadMask(name: String): Array[Colour] = { - val (width, height, data) = loadImageData(name) - assertEquals("Unexpected width for " + name, 32, width) - assertEquals("Unexpected height for " + name, 32, height) - data + private def loadMask(name: String): Array[Int] = { + val image = loadImageData(name) + assertEquals("Unexpected width for " + name, 32, image.getWidth) + assertEquals("Unexpected height for " + name, 32, image.getHeight) + image.getRGB(0, 0, image.getWidth, image.getHeight, null, 0, image.getWidth) } - private def loadImage(name: String): Array[Colour] = loadImageData(name)._3 + private def loadImage(name: String): Array[Colour] = { + val image = loadImageData(name) + image + .getRGB(0, 0, image.getWidth, image.getHeight, null, 0, image.getWidth) + .map(new ColourARGB(_): Colour) + } - private def loadImageData(name: String): (Int, Int, Array[Colour]) = { + private def loadImageData(name: String): BufferedImage = { val path = GateWireGoldenTest.maskPath + name + ".png" val stream = getClass.getResourceAsStream(path) assertNotNull("Missing wire mask " + path, stream) @@ -110,10 +116,7 @@ class GateWireGoldenTest { try ImageIO.read(stream) finally stream.close() assertNotNull("Invalid wire mask " + path, image) - val data = image - .getRGB(0, 0, image.getWidth, image.getHeight, null, 0, image.getWidth) - .map(new ColourARGB(_): Colour) - (image.getWidth, image.getHeight, data) + image } private def digestRectangles(rectangles: Seq[Rectangle4i]): String = digest { From 898f7d4fb4354b390cf74fe96e4c7dfa539032ca Mon Sep 17 00:00:00 2001 From: boubou19 Date: Fri, 14 Aug 2026 01:15:50 +0200 Subject: [PATCH 06/14] perf(integration): retain baked base models --- src/main/scala/mrtjp/projectred/integration/components.scala | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/scala/mrtjp/projectred/integration/components.scala b/src/main/scala/mrtjp/projectred/integration/components.scala index 544edda7e..92644695b 100644 --- a/src/main/scala/mrtjp/projectred/integration/components.scala +++ b/src/main/scala/mrtjp/projectred/integration/components.scala @@ -56,7 +56,7 @@ private[integration] object ComponentModelBakery { m } - def bakeDynamic(base: CCModel) = Array(base.copy, reverseFacing(base.copy)) + def bakeDynamic(base: CCModel) = Array(base, reverseFacing(base.copy)) private def reverseFacing(m: CCModel) = { for (i <- 0 until m.verts.length by 4) { From 8b4bb0a4aa01a3846852223400ed4ab6ab423d70 Mon Sep 17 00:00:00 2001 From: boubou19 Date: Fri, 14 Aug 2026 01:29:28 +0200 Subject: [PATCH 07/14] test(integration): cover lazy renderer slots --- .../projectred/integration/gaterenders.scala | 27 +++++++ .../integration/LazyRendererSlotsTest.scala | 73 +++++++++++++++++++ 2 files changed, 100 insertions(+) create mode 100644 src/test/scala/mrtjp/projectred/integration/LazyRendererSlotsTest.scala diff --git a/src/main/scala/mrtjp/projectred/integration/gaterenders.scala b/src/main/scala/mrtjp/projectred/integration/gaterenders.scala index 269c7dea2..bb9b9d613 100644 --- a/src/main/scala/mrtjp/projectred/integration/gaterenders.scala +++ b/src/main/scala/mrtjp/projectred/integration/gaterenders.scala @@ -6,6 +6,7 @@ package mrtjp.projectred.integration import java.util.Random +import java.util.concurrent.atomic.AtomicReferenceArray import codechicken.lib.math.MathHelper import codechicken.lib.render.{CCRenderState, TextureUtils} @@ -16,6 +17,32 @@ import mrtjp.projectred.integration.ComponentStore._ import net.minecraft.client.renderer.texture.IIconRegister import net.minecraft.item.ItemStack +private[integration] class LazyRendererSlots[A <: AnyRef]( + factories: Array[() => A] +) { + private val builders = factories.clone() + private val values = new AtomicReferenceArray[A](factories.length) + + def length = builders.length + + def apply(index: Int): A = { + var value = values.get(index) + if (value == null) synchronized { + value = values.get(index) + if (value == null) { + value = builders(index)() + values.set(index, value) + } + } + value + } + + def replace(index: Int, factory: () => A): Unit = synchronized { + builders(index) = factory + values.set(index, null.asInstanceOf[A]) + } +} + object RenderGate { var renderers = buildRenders() diff --git a/src/test/scala/mrtjp/projectred/integration/LazyRendererSlotsTest.scala b/src/test/scala/mrtjp/projectred/integration/LazyRendererSlotsTest.scala new file mode 100644 index 000000000..fccf9c28a --- /dev/null +++ b/src/test/scala/mrtjp/projectred/integration/LazyRendererSlotsTest.scala @@ -0,0 +1,73 @@ +package mrtjp.projectred.integration + +import java.util.concurrent.atomic.AtomicInteger +import java.util.concurrent.{Callable, CountDownLatch, Executors, TimeUnit} + +import org.junit.Assert.{assertEquals, assertSame} +import org.junit.Test + +class LazyRendererSlotsTest { + + @Test + def constructsSlotOnceOnConcurrentFirstAccess(): Unit = { + val constructions = new AtomicInteger + val slots = new LazyRendererSlots[String](Array(() => { + constructions.incrementAndGet() + new String("renderer") + })) + assertEquals(0, constructions.get()) + + val start = new CountDownLatch(1) + val executor = Executors.newFixedThreadPool(8) + try { + val futures = (0 until 8).map { _ => + executor.submit(new Callable[String] { + override def call(): String = { + start.await() + slots(0) + } + }) + } + start.countDown() + + val expected = futures.head.get(5, TimeUnit.SECONDS) + futures.tail.foreach(f => + assertSame(expected, f.get(5, TimeUnit.SECONDS)) + ) + assertEquals(1, constructions.get()) + } finally executor.shutdownNow() + } + + @Test + def replacementStaysLazyBeforeAndAfterAccess(): Unit = { + val originalConstructions = new AtomicInteger + val replacementConstructions = new AtomicInteger + val slots = new LazyRendererSlots[String](Array(() => { + originalConstructions.incrementAndGet() + "original" + })) + + slots.replace( + 0, + () => { + replacementConstructions.incrementAndGet() + "first replacement" + } + ) + assertEquals(0, originalConstructions.get()) + assertEquals(0, replacementConstructions.get()) + assertEquals("first replacement", slots(0)) + assertEquals(1, replacementConstructions.get()) + + slots.replace( + 0, + () => { + replacementConstructions.incrementAndGet() + "second replacement" + } + ) + assertEquals(1, replacementConstructions.get()) + assertEquals("second replacement", slots(0)) + assertEquals(2, replacementConstructions.get()) + } +} From 36a6ce198329d4141edfaab9528e1db133580193 Mon Sep 17 00:00:00 2001 From: boubou19 Date: Fri, 14 Aug 2026 01:30:56 +0200 Subject: [PATCH 08/14] perf(integration): build gate renderers lazily --- .../projectred/integration/gaterenders.scala | 87 +++++++++---------- 1 file changed, 43 insertions(+), 44 deletions(-) diff --git a/src/main/scala/mrtjp/projectred/integration/gaterenders.scala b/src/main/scala/mrtjp/projectred/integration/gaterenders.scala index bb9b9d613..2a76e3b29 100644 --- a/src/main/scala/mrtjp/projectred/integration/gaterenders.scala +++ b/src/main/scala/mrtjp/projectred/integration/gaterenders.scala @@ -13,6 +13,7 @@ import codechicken.lib.render.{CCRenderState, TextureUtils} import codechicken.lib.vec.{RedundantTransformation, Transformation, Vector3} import mrtjp.core.color.Colors import mrtjp.projectred.core.TFaceOrient.flipMaskZ +import mrtjp.projectred.core.Configurator import mrtjp.projectred.integration.ComponentStore._ import net.minecraft.client.renderer.texture.IIconRegister import net.minecraft.item.ItemStack @@ -44,48 +45,49 @@ private[integration] class LazyRendererSlots[A <: AnyRef]( } object RenderGate { - var renderers = buildRenders() - - def buildRenders() = Seq[GateRenderer[_]]( - new RenderOR, - new RenderNOR, - new RenderNOT, - new RenderAND, - new RenderNAND, - new RenderXOR, - new RenderXNOR, - new RenderBuffer, - new RenderMultiplexer, - new RenderPulse, - new RenderRepeater, - new RenderRandomizer, - new RenderSRLatch, - new RenderToggleLatch, - new RenderTransparentLatch, - new RenderLightSensor, - new RenderRainSensor, - new RenderTimer, - new RenderSequencer, - new RenderCounter, - new RenderStateCell, - new RenderSynchronizer, - new RenderBusXcvr, - new RenderNullCell, - new RenderInvertCell, - new RenderBufferCell, - new RenderComparator, - new RenderANDCell, - new RenderBusRandomizer, - new RenderBusConverter, - new RenderBusInputPanel, - new RenderStackingLatch, - new RenderSegmentDisplay, - new RenderDecodingRand, - GateRenderer.blank // circuit gate renderer will be injected. + private val renderers = new LazyRendererSlots[GateRenderer[_]]( + Array[() => GateRenderer[_]]( + () => new RenderOR, + () => new RenderNOR, + () => new RenderNOT, + () => new RenderAND, + () => new RenderNAND, + () => new RenderXOR, + () => new RenderXNOR, + () => new RenderBuffer, + () => new RenderMultiplexer, + () => new RenderPulse, + () => new RenderRepeater, + () => new RenderRandomizer, + () => new RenderSRLatch, + () => new RenderToggleLatch, + () => new RenderTransparentLatch, + () => new RenderLightSensor, + () => new RenderRainSensor, + () => new RenderTimer, + () => new RenderSequencer, + () => new RenderCounter, + () => new RenderStateCell, + () => new RenderSynchronizer, + () => new RenderBusXcvr, + () => new RenderNullCell, + () => new RenderInvertCell, + () => new RenderBufferCell, + () => new RenderComparator, + () => new RenderANDCell, + () => new RenderBusRandomizer, + () => new RenderBusConverter, + () => new RenderBusInputPanel, + () => new RenderStackingLatch, + () => new RenderSegmentDisplay, + () => new RenderDecodingRand, + () => GateRenderer.blank // circuit gate renderer will be injected. + ) ) def registerIcons(reg: IIconRegister) { - for (r <- renderers) r.registerIcons(reg) + if (!Configurator.logicwires3D) + for (i <- 0 until renderers.length) renderers(i).registerIcons(reg) } def renderStatic(gate: GatePart, pos: Vector3) { @@ -119,11 +121,8 @@ object RenderGate { .spawnParticles(gate, rand) } - def hotswap(r: GateRenderer[_], meta: Int) { - val ar = renderers.toArray - ar(meta) = r - renderers = ar.toSeq - } + def hotswap(r: => GateRenderer[_], meta: Int): Unit = + renderers.replace(meta, () => r) } abstract class GateRenderer[T <: GatePart] { From 805d40bb088adf13948c27156284c616519db900 Mon Sep 17 00:00:00 2001 From: boubou19 Date: Fri, 14 Aug 2026 01:32:27 +0200 Subject: [PATCH 09/14] perf(integration): load component models lazily --- .../projectred/integration/components.scala | 71 ++++++++++--------- 1 file changed, 36 insertions(+), 35 deletions(-) diff --git a/src/main/scala/mrtjp/projectred/integration/components.scala b/src/main/scala/mrtjp/projectred/integration/components.scala index 92644695b..a6f626caa 100644 --- a/src/main/scala/mrtjp/projectred/integration/components.scala +++ b/src/main/scala/mrtjp/projectred/integration/components.scala @@ -72,51 +72,52 @@ private[integration] object ComponentModelBakery { } object ComponentStore { - val base = loadBase("base") - val lightChip = loadModel("chip") - val leverOn = loadModel("leveron") - val leverOff = loadModel("leveroff") - val solarArray = loadModel("solar") - val rainSensor = loadModel("rainsensor") - val pointer = loadModel("pointer") - val busXcvr = loadModel("array/busxcvr") - val lightPanel1 = loadModel("array/lightpanel1") - val lightPanel2 = loadModel("array/lightpanel2") - val busRand = loadModel("array/busrand") - val busConv = loadModel("array/busconv") - val signalPanel = loadModel("array/signalpanel") - val busInput = loadModel("array/businput") - val icBundled = loadModel("array/icbundled") - - val nullCellWireBottom = + lazy val base = loadBase("base") + lazy val lightChip = loadModel("chip") + lazy val leverOn = loadModel("leveron") + lazy val leverOff = loadModel("leveroff") + lazy val solarArray = loadModel("solar") + lazy val rainSensor = loadModel("rainsensor") + lazy val pointer = loadModel("pointer") + lazy val busXcvr = loadModel("array/busxcvr") + lazy val lightPanel1 = loadModel("array/lightpanel1") + lazy val lightPanel2 = loadModel("array/lightpanel2") + lazy val busRand = loadModel("array/busrand") + lazy val busConv = loadModel("array/busconv") + lazy val signalPanel = loadModel("array/signalpanel") + lazy val busInput = loadModel("array/businput") + lazy val icBundled = loadModel("array/icbundled") + + lazy val nullCellWireBottom = loadModel("array/nullcellbottomwire").apply(new Translation(0.5, 0, 0.5)) - val nullCellWireTop = + lazy val nullCellWireTop = loadModel("array/nullcelltopwire").apply(new Translation(0.5, 0, 0.5)) - val nullCellBase = loadBase("array/nullcellbase") - val extendedCellWireBottom = loadModel("array/extendedcellbottomwire").apply( - new Translation(0.5, 0, 0.5) - ) - val extendedCellWireTop = + lazy val nullCellBase = loadBase("array/nullcellbase") + lazy val extendedCellWireBottom = + loadModel("array/extendedcellbottomwire").apply( + new Translation(0.5, 0, 0.5) + ) + lazy val extendedCellWireTop = loadModel("array/extendedcelltopwire").apply(new Translation(0.5, 0, 0.5)) - val extendedCellBase = loadBase("array/extendedcellbase") - val cellWireSide = + lazy val extendedCellBase = loadBase("array/extendedcellbase") + lazy val cellWireSide = loadModel("array/cellsidewire").apply(new Translation(0.5, 0, 0.5)) - val cellFrame = + lazy val cellFrame = loadModel("array/cellstand").apply(new Translation(0.5, 0, 0.5)) - val cellPlate = + lazy val cellPlate = loadModel("array/cellplate").apply(new Translation(0.5, 0, 0.5)) - val stackLatchWireBottom = + lazy val stackLatchWireBottom = loadModel("array/stacklatchwire").apply(new Translation(0.5, 0, 0.5)) - val stackStand = loadModel("array/latchstand") + lazy val stackStand = loadModel("array/latchstand") - val sevenSeg = loadCorrectedModels("array/7seg") - val sixteenSeg = loadCorrectedModels("array/16seg") - val segbus = loadModel("array/segbus") + lazy val sevenSeg = loadCorrectedModels("array/7seg") + lazy val sixteenSeg = loadCorrectedModels("array/16seg") + lazy val segbus = loadModel("array/segbus") - val icChip = loadCorrectedModel("icchip") - val icGlass = loadCorrectedModel("icglass") - val icHousing = loadCorrectedModel("ichousing") + lazy val icChip = loadCorrectedModel("icchip") + lazy val icGlass = loadCorrectedModel("icglass") + lazy val icHousing = loadCorrectedModel("ichousing") var baseIcon: IIcon = null var wireIcons: Array[IIcon] = new Array[IIcon](3) From efa2018e607cb1784a6f8eebad615a74e9bc97fe Mon Sep 17 00:00:00 2001 From: boubou19 Date: Fri, 14 Aug 2026 11:17:13 +0200 Subject: [PATCH 10/14] perf(integration): precompute wire layouts --- .../integration/GateWireMasks.scala | 212 ++++++++++++++++++ .../projectred/integration/components.scala | 63 +++++- .../integration/GateWireGoldenTest.scala | 37 ++- .../integration/GateWireMasksTest.scala | 109 +++++++++ 4 files changed, 398 insertions(+), 23 deletions(-) create mode 100644 src/main/scala/mrtjp/projectred/integration/GateWireMasks.scala create mode 100644 src/test/scala/mrtjp/projectred/integration/GateWireMasksTest.scala diff --git a/src/main/scala/mrtjp/projectred/integration/GateWireMasks.scala b/src/main/scala/mrtjp/projectred/integration/GateWireMasks.scala new file mode 100644 index 000000000..e4a8d8a11 --- /dev/null +++ b/src/main/scala/mrtjp/projectred/integration/GateWireMasks.scala @@ -0,0 +1,212 @@ +package mrtjp.projectred.integration + +import codechicken.lib.vec.Rectangle4i + +private[integration] object GateWireMasks { + private val names = Array[String]( + "OR-0", + "OR-1", + "OR-2", + "OR-3", + "NOR-0", + "NOR-1", + "NOR-2", + "NOR-3", + "NOT-0", + "NOT-1", + "NOT-2", + "NOT-3", + "AND-0", + "AND-1", + "AND-2", + "AND-3", + "NAND-0", + "NAND-1", + "NAND-2", + "NAND-3", + "XOR-0", + "XOR-1", + "XOR-2", + "XOR-3", + "XNOR-0", + "XNOR-1", + "XNOR-2", + "XNOR-3", + "XNOR-4", + "BUFFER-0", + "BUFFER-1", + "BUFFER-2", + "BUFFER-3", + "MULTIPLEXER-0", + "MULTIPLEXER-1", + "MULTIPLEXER-2", + "MULTIPLEXER-3", + "MULTIPLEXER-4", + "MULTIPLEXER-5", + "PULSE-0", + "PULSE-1", + "PULSE-2", + "REPEATER-0", + "REPEATER-1", + "RAND-0", + "RAND-1", + "RAND-2", + "RAND-3", + "RAND-4", + "RAND-5", + "RAND-6", + "RSLATCH-0", + "RSLATCH-1", + "RSLATCH2-0", + "RSLATCH2-1", + "RSLATCH2-2", + "RSLATCH2-3", + "TOGLATCH-0", + "TOGLATCH-1", + "TRANSLATCH-0", + "TRANSLATCH-1", + "TRANSLATCH-2", + "TRANSLATCH-3", + "TRANSLATCH-4", + "LIGHTSENSOR-0", + "RAINSENSOR-0", + "TIME-0", + "TIME-1", + "TIME-2", + "COUNT-0", + "COUNT-1", + "STATECELL-0", + "STATECELL-1", + "STATECELL-2", + "STATECELL-3", + "STATECELL-4", + "SYNC-0", + "SYNC-1", + "SYNC-2", + "SYNC-3", + "SYNC-4", + "SYNC-5", + "BUSXCVR-0", + "BUSXCVR-1", + "COMPARATOR-0", + "COMPARATOR-1", + "COMPARATOR-2", + "COMPARATOR-3", + "BUSRAND1-0", + "BUSRAND1-1", + "BUSRAND2-0", + "BUSRAND2-1", + "BUSCONV-0", + "BUSCONV-1", + "BUSCONV-2", + "BUSINPUT-0", + "INVCELL-0", + "BUFFCELL-0", + "BUFFCELL-1", + "ANDCELL-0", + "ANDCELL-1", + "STACKLATCH-0", + "STACKLATCH-1", + "STACKLATCH-2", + "STACKLATCH-3", + "STACKLATCH-4", + "DECRAND-0", + "DECRAND-1", + "DECRAND-2", + "DECRAND-3", + "DECRAND-4", + "DECRAND-5", + "IC1-0", + "IC1-1", + "IC1-2", + "IC1-3", + "IC2-0", + "IC2-1", + "IC2-2", + "IC2-3" + ) + private val offsets = Array[Int]( + 0, 3, 5, 6, 8, 11, 13, 14, 16, 19, 21, 22, 24, 26, 27, 28, 29, 32, 35, 36, + 37, 45, 50, 54, 58, 63, 68, 72, 76, 79, 82, 86, 87, 91, 96, 101, 104, 109, + 110, 111, 117, 120, 124, 127, 130, 131, 133, 134, 136, 137, 138, 139, 144, + 149, 154, 157, 162, 165, 168, 169, 174, 177, 180, 182, 183, 184, 185, 191, + 197, 198, 202, 206, 210, 212, 214, 217, 218, 221, 224, 228, 231, 233, 235, + 237, 238, 241, 242, 245, 250, 251, 252, 255, 258, 259, 260, 262, 263, 264, + 265, 269, 274, 275, 278, 282, 284, 286, 287, 292, 298, 300, 301, 303, 307, + 308, 309, 310, 311, 312, 313, 314, 315 + ) + private val packedRectangles = Array[Int]( + 0x0f04020a, 0x0d0c0202, 0x110c0202, 0x180f0602, 0x12110602, 0x0f12020c, + 0x020f0602, 0x08110802, 0x0f02020c, 0x0d0c0202, 0x110c0202, 0x180f0602, + 0x12110602, 0x0f12020c, 0x020f0602, 0x08110802, 0x0f02020a, 0x0d0a0202, + 0x110a0202, 0x140d0206, 0x160f0802, 0x0f10020e, 0x0a0d0206, 0x020f0802, + 0x0f020208, 0x060a1402, 0x0f10020e, 0x020f0602, 0x180f0602, 0x0f020206, + 0x11080202, 0x060a1402, 0x0f120204, 0x0d160204, 0x0f1a0204, 0x020f0802, + 0x160f0802, 0x0f020204, 0x0a060c02, 0x08080204, 0x16080204, 0x060a0202, + 0x0a0a0202, 0x140a0202, 0x180a0202, 0x090f0602, 0x110f0602, 0x0f110204, + 0x0d130202, 0x11130202, 0x020f0602, 0x04110204, 0x06150202, 0x08170802, + 0x180f0602, 0x1a110204, 0x18150202, 0x10170802, 0x0f020204, 0x0a060602, + 0x08080204, 0x060a0202, 0x0a0a0202, 0x0f020204, 0x10060602, 0x16080204, + 0x140a0202, 0x180a0202, 0x020f0602, 0x04110204, 0x06150202, 0x08170802, + 0x180f0602, 0x1a110204, 0x18150202, 0x10170802, 0x080f0602, 0x120f0602, + 0x0e110402, 0x0f060208, 0x0d0c0202, 0x110c0202, 0x14040206, 0x16060202, + 0x18080208, 0x1a100402, 0x0f12020c, 0x0a040206, 0x08060202, 0x06080208, + 0x02100402, 0x0f020204, 0x0a060602, 0x08080204, 0x060a0202, 0x0a0a0202, + 0x0f020204, 0x10060602, 0x16080204, 0x140a0202, 0x180a0202, 0x08100204, + 0x0c120206, 0x0a140202, 0x16120202, 0x10140602, 0x15160204, 0x111a0402, + 0x0f1c0202, 0x020f0802, 0x160f0802, 0x09060602, 0x07080208, 0x050e0202, + 0x090e0202, 0x0b100206, 0x0d120a02, 0x07120206, 0x09180602, 0x0f1a0204, + 0x0f060602, 0x15080208, 0x130e0202, 0x170e0202, 0x0f020206, 0x17060414, + 0x11080602, 0x130c020e, 0x11120202, 0x0f14020a, 0x0f020206, 0x180f0602, + 0x16110206, 0x0f180206, 0x020f0602, 0x08110206, 0x0f08020e, 0x11160602, + 0x09160602, 0x08070204, 0x060b020c, 0x020f0402, 0x08170202, 0x0a190802, + 0x0e050802, 0x16070202, 0x1809020c, 0x1a0f0402, 0x16150204, 0x12070206, + 0x140d0204, 0x16110208, 0x12170206, 0x14190202, 0x12050802, 0x1a070208, + 0x1c0f0202, 0x0c030206, 0x0a050202, 0x08070208, 0x0a0f0204, 0x0c130206, + 0x020f0202, 0x04110208, 0x06190802, 0x020f0802, 0x100f0602, 0x0a110602, + 0x160f0802, 0x0f02020a, 0x15080602, 0x070a0802, 0x110a0402, 0x1b0a0206, + 0x0f0e020a, 0x07180802, 0x0f1a0204, 0x070e0208, 0x05140202, 0x09140202, + 0x170d0206, 0x110f0602, 0x020f0602, 0x0f12020c, 0x0f0c0212, 0x0a030206, + 0x08050202, 0x06070208, 0x080d0202, 0x020f0402, 0x0a0f0402, 0x14030206, + 0x16050202, 0x18070208, 0x160d0202, 0x100f0602, 0x1a0f0402, 0x0f10020e, + 0x020f0802, 0x100f0602, 0x0a110602, 0x04190602, 0x18030206, 0x16050202, + 0x1a050202, 0x160f0802, 0x0e040206, 0x0c060202, 0x0a080204, 0x0c0c0206, + 0x0d160204, 0x0f1a0204, 0x14100402, 0x10120402, 0x020f0202, 0x04110202, + 0x06130402, 0x180f0602, 0x0c050402, 0x0a070202, 0x08090206, 0x10050402, + 0x14070202, 0x16090206, 0x08140204, 0x16140204, 0x0a180c02, 0x0f1a0204, + 0x0e0f0402, 0x0c110202, 0x12110202, 0x020f0202, 0x04110202, 0x1c0f0202, + 0x1a110202, 0x160e0804, 0x1e0e0204, 0x000e0a04, 0x0f020206, 0x11080402, + 0x150a0206, 0x160f0802, 0x15100208, 0x11180402, 0x0f1a0204, 0x020f0202, + 0x100f0602, 0x04110204, 0x0e110204, 0x06150802, 0x160f0802, 0x020f0802, + 0x160d0402, 0x1a0f0402, 0x16110402, 0x060d0402, 0x020f0402, 0x06110402, + 0x160f0802, 0x020f0802, 0x0e10040e, 0x0e1e0402, 0x0e000404, 0x0b0f0602, + 0x11190602, 0x0f0f0602, 0x15110206, 0x13150202, 0x17150202, 0x0f020214, + 0x0d0f0202, 0x110f0202, 0x0d140202, 0x11140202, 0x0f180206, 0x0b030602, + 0x09050206, 0x0b0b0210, 0x0e0f0802, 0x180f0202, 0x16110202, 0x18130206, + 0x14150206, 0x10170402, 0x0d070602, 0x0f090206, 0x0f180206, 0x09040802, + 0x07060202, 0x09080210, 0x03100208, 0x05180202, 0x0f050204, 0x11090204, + 0x0f0b0202, 0x130b0402, 0x170d0202, 0x190f0202, 0x111a0202, 0x0f1c0202, + 0x0d1a0202, 0x050f1002, 0x15110208, 0x13020206, 0x15040402, 0x19060206, + 0x1b0c0206, 0x0f020204, 0x1a0f0402, 0x0f1a0204, 0x020f0402, 0x0e020404, + 0x1a0e0404, 0x0e1a0404, 0x020e0404 + ) + + def rectangles(name: String): Seq[Rectangle4i] = { + val index = names.indexOf(name) + if (index < 0) return null + + val result = Seq.newBuilder[Rectangle4i] + var i = offsets(index) + while (i < offsets(index + 1)) { + val packed = packedRectangles(i) + result += new Rectangle4i( + packed >>> 24, + packed >>> 16 & 0xff, + packed >>> 8 & 0xff, + packed & 0xff + ) + i += 1 + } + result.result() + } +} diff --git a/src/main/scala/mrtjp/projectred/integration/components.scala b/src/main/scala/mrtjp/projectred/integration/components.scala index a6f626caa..4f66ef118 100644 --- a/src/main/scala/mrtjp/projectred/integration/components.scala +++ b/src/main/scala/mrtjp/projectred/integration/components.scala @@ -5,6 +5,9 @@ */ package mrtjp.projectred.integration +import java.util.{List => JList} +import javax.imageio.ImageIO + import codechicken.lib.colour.Colour import codechicken.lib.lighting.LightModel.Light import codechicken.lib.lighting.{LightModel, PlanarLightModel} @@ -17,6 +20,8 @@ import mrtjp.core.color.Colors import mrtjp.core.vec.{InvertX, VecLib} import mrtjp.projectred.core.{Configurator, RenderHalo} import mrtjp.projectred.transmission.{UVT, WireModelGen} +import net.minecraft.client.Minecraft +import net.minecraft.client.resources.{IResource, IResourceManager} import net.minecraft.client.renderer.texture.IIconRegister import net.minecraft.util.{IIcon, ResourceLocation} @@ -238,14 +243,41 @@ object ComponentStore { } def generateWireModel(name: String) = { - val data = TextureUtils.loadTextureData( - new ResourceLocation( - "projectred:textures/blocks/integration/surface/" + name + ".png" - ) + val rectangles = loadWireRectangles( + name, + Minecraft.getMinecraft.getResourceManager ) - if (Configurator.logicwires3D) new WireModel3D(data) - else new WireModel2D(data) + if (Configurator.logicwires3D) new WireModel3D(rectangles) + else new WireModel2D(rectangles) + } + + private[integration] def loadWireRectangles( + name: String, + resourceManager: IResourceManager + ): Seq[Rectangle4i] = { + val location = new ResourceLocation( + "projectred:textures/blocks/integration/surface/" + name + ".png" + ) + val resources = resourceManager + .getAllResources(location) + .asInstanceOf[JList[IResource]] + .map(_.getInputStream) + try { + val builtIn = GateWireMasks.rectangles(name) + if (builtIn != null && resources.length == 1) return builtIn + if (resources.isEmpty) + throw new RuntimeException("Wire mask not found: " + location) + + val image = ImageIO.read(resources.last) + if (image == null) + throw new RuntimeException("Invalid wire mask: " + location) + if (image.getWidth != 32 || image.getHeight != 32) + throw new RuntimeException( + "Wire mask must be 32x32: " + location + " was " + image.getWidth + "x" + image.getHeight + ) + TWireModel.rectangulate(image.getRGB(0, 0, 32, 32, null, 0, 32)) + } finally resources.foreach(_.close()) } } @@ -453,9 +485,11 @@ object TWireModel { } } -class WireModel3D(data: Array[Int]) - extends SingleComponentModel(WireModel3D.generateModel(data)) +class WireModel3D private[integration] (wireRectangles: Seq[Rectangle4i]) + extends SingleComponentModel(WireModel3D.generateModel(wireRectangles)) with TWireModel { + def this(data: Array[Int]) = this(TWireModel.rectangulate(data)) + override def getUVT = if (disabled) new IconTransformation(wireIcons(0)) else if (on) new MultiIconTransformation(wireIcons(0), wireIcons(2)) @@ -463,8 +497,10 @@ class WireModel3D(data: Array[Int]) } object WireModel3D { - def generateModel(data: Array[Int]) = { - val wireRectangles = TWireModel.rectangulate(data) + def generateModel(data: Array[Int]): CCModel = + generateModel(TWireModel.rectangulate(data)) + + private[integration] def generateModel(wireRectangles: Seq[Rectangle4i]) = { val model = CCModel.quadModel(wireRectangles.length * 40) var i = 0 for (rect <- wireRectangles) { @@ -498,7 +534,11 @@ object WireModel3D { } } -class WireModel2D(data: Array[Int]) extends ComponentModel with TWireModel { +class WireModel2D private[integration] (wireRectangles: Seq[Rectangle4i]) + extends ComponentModel + with TWireModel { + def this(data: Array[Int]) = this(TWireModel.rectangulate(data)) + var icons: Array[TextureSpecial] = _ private val iconIndex = WireModel2D.claimIdx() @@ -512,7 +552,6 @@ class WireModel2D(data: Array[Int]) extends ComponentModel with TWireModel { } override def registerIcons(reg: IIconRegister) { - val wireRectangles = TWireModel.rectangulate(data) val texMap = TWireModel.buildTexMap(wireRectangles) icons = new Array[TextureSpecial](wireData.length) for (tex <- 0 until icons.length) { diff --git a/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala b/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala index 39d0ebc6c..be5cc05bc 100644 --- a/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala +++ b/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala @@ -43,7 +43,7 @@ class GateWireGoldenTest { @Test def bakedModelsDoNotShareMutableGeometry(): Unit = { - val base = WireModel3D.generateModel(loadMask("OR-0")) + val base = WireModel3D.generateModel(GateWireTestData.loadMask("OR-0")) val pair = ComponentModelBakery.bakeDynamic(base) assertNotSame(pair(0), pair(1)) @@ -58,7 +58,7 @@ class GateWireGoldenTest { } private def characterize(name: String): String = { - val data = loadMask(name) + val data = GateWireTestData.loadMask(name) val rectangles = TWireModel.rectangulate(data) val model = WireModel3D.generateModel(data) @@ -73,7 +73,7 @@ class GateWireGoldenTest { private def characterizeBaking(name: String): String = { val modelPair = ComponentModelBakery.bakeDynamic( - WireModel3D.generateModel(loadMask(name)) + WireModel3D.generateModel(GateWireTestData.loadMask(name)) ) val orientedDigest = digest { out => for (orient <- 0 until 48) { @@ -86,20 +86,15 @@ class GateWireGoldenTest { } private def characterize2D(name: String, wireData: Array[Array[Colour]]) = { - val texMap = TWireModel.buildTexMap(TWireModel.rectangulate(loadMask(name))) + val texMap = TWireModel.buildTexMap( + TWireModel.rectangulate(GateWireTestData.loadMask(name)) + ) val textures = wireData.indices.map { tex => digestInts(TWireModel.buildTexture(texMap, wireData, tex)) } (name +: digestInts(texMap) +: textures).mkString(" ") } - private def loadMask(name: String): Array[Int] = { - val image = loadImageData(name) - assertEquals("Unexpected width for " + name, 32, image.getWidth) - assertEquals("Unexpected height for " + name, 32, image.getHeight) - image.getRGB(0, 0, image.getWidth, image.getHeight, null, 0, image.getWidth) - } - private def loadImage(name: String): Array[Colour] = { val image = loadImageData(name) image @@ -261,3 +256,23 @@ object GateWireGoldenTest { require(maskNames.length == 120) } + +private[integration] object GateWireTestData { + def loadMask(name: String): Array[Int] = { + val image = loadImageData(name) + assertEquals("Unexpected width for " + name, 32, image.getWidth) + assertEquals("Unexpected height for " + name, 32, image.getHeight) + image.getRGB(0, 0, image.getWidth, image.getHeight, null, 0, image.getWidth) + } + + private def loadImageData(name: String): BufferedImage = { + val path = GateWireGoldenTest.maskPath + name + ".png" + val stream = getClass.getResourceAsStream(path) + assertNotNull("Missing wire mask " + path, stream) + val image = + try ImageIO.read(stream) + finally stream.close() + assertNotNull("Invalid wire mask " + path, image) + image + } +} diff --git a/src/test/scala/mrtjp/projectred/integration/GateWireMasksTest.scala b/src/test/scala/mrtjp/projectred/integration/GateWireMasksTest.scala new file mode 100644 index 000000000..5ec662759 --- /dev/null +++ b/src/test/scala/mrtjp/projectred/integration/GateWireMasksTest.scala @@ -0,0 +1,109 @@ +package mrtjp.projectred.integration + +import java.io.{ByteArrayInputStream, InputStream} +import java.util.{ArrayList, Collections, List => JList, Set => JSet} + +import codechicken.lib.vec.Rectangle4i +import net.minecraft.client.resources.data.IMetadataSection +import net.minecraft.client.resources.{IResource, IResourceManager} +import net.minecraft.util.ResourceLocation +import org.apache.commons.io.IOUtils +import org.junit.Assert.{assertEquals, assertTrue} +import org.junit.Test + +class GateWireMasksTest { + + @Test + def precomputedLayoutsMatchSourceMasks(): Unit = { + GateWireGoldenTest.maskNames.foreach { name => + assertEquals( + name, + tuples(TWireModel.rectangulate(GateWireTestData.loadMask(name))), + tuples(GateWireMasks.rectangles(name)) + ) + } + } + + @Test + def builtInLayoutClosesButDoesNotReadSingleResource(): Unit = { + val stream = + new TrackingInputStream(Array.emptyByteArray, failOnRead = true) + val actual = ComponentStore.loadWireRectangles("OR-0", manager(stream)) + + assertEquals(tuples(GateWireMasks.rectangles("OR-0")), tuples(actual)) + assertTrue(stream.closed) + } + + @Test + def resourcePackOverrideUsesHighestPriorityMask(): Unit = { + val builtIn = + new TrackingInputStream(Array.emptyByteArray, failOnRead = true) + val overrideStream = new TrackingInputStream(resourceBytes("OR-1")) + val actual = ComponentStore.loadWireRectangles( + "OR-0", + manager(builtIn, overrideStream) + ) + + assertEquals(tuples(GateWireMasks.rectangles("OR-1")), tuples(actual)) + assertTrue(builtIn.closed) + assertTrue(overrideStream.closed) + } + + @Test + def unknownMaskFallsBackToPng(): Unit = { + val stream = new TrackingInputStream(resourceBytes("OR-1")) + val actual = ComponentStore.loadWireRectangles("ADDON", manager(stream)) + + assertEquals(tuples(GateWireMasks.rectangles("OR-1")), tuples(actual)) + assertTrue(stream.closed) + } + + private def tuples(rectangles: Seq[Rectangle4i]) = + rectangles.map(r => (r.x, r.y, r.w, r.h)) + + private def resourceBytes(name: String): Array[Byte] = { + val stream = + getClass.getResourceAsStream(GateWireGoldenTest.maskPath + name + ".png") + try IOUtils.toByteArray(stream) + finally stream.close() + } + + private def manager(streams: TrackingInputStream*) = new IResourceManager { + private val resources = new ArrayList[IResource]() + streams.foreach(stream => resources.add(new TestResource(stream))) + + override def getResourceDomains: JSet[_] = Collections.emptySet[AnyRef]() + override def getResource(location: ResourceLocation): IResource = + resources.get(resources.size() - 1) + override def getAllResources(location: ResourceLocation): JList[_] = + resources + } + + private class TestResource(stream: InputStream) extends IResource { + override def getInputStream = stream + override def hasMetadata = false + override def getMetadata(name: String): IMetadataSection = null + } + + private class TrackingInputStream( + bytes: Array[Byte], + failOnRead: Boolean = false + ) extends ByteArrayInputStream(bytes) { + var closed = false + + override def read(): Int = { + if (failOnRead) throw new AssertionError("Stream was read") + super.read() + } + + override def read(buffer: Array[Byte], offset: Int, length: Int): Int = { + if (failOnRead) throw new AssertionError("Stream was read") + super.read(buffer, offset, length) + } + + override def close(): Unit = { + closed = true + super.close() + } + } +} From bbcbe375cbe1f04fc39f2629760236eb7e767135 Mon Sep 17 00:00:00 2001 From: boubou19 Date: Fri, 14 Aug 2026 11:18:28 +0200 Subject: [PATCH 11/14] perf(integration): own generated wire models --- .../projectred/integration/components.scala | 20 +++++++++++++++++-- .../integration/GateWireGoldenTest.scala | 16 ++++++++++++--- 2 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/main/scala/mrtjp/projectred/integration/components.scala b/src/main/scala/mrtjp/projectred/integration/components.scala index 4f66ef118..d1c02382e 100644 --- a/src/main/scala/mrtjp/projectred/integration/components.scala +++ b/src/main/scala/mrtjp/projectred/integration/components.scala @@ -486,14 +486,24 @@ object TWireModel { } class WireModel3D private[integration] (wireRectangles: Seq[Rectangle4i]) - extends SingleComponentModel(WireModel3D.generateModel(wireRectangles)) + extends ComponentModel with TWireModel { def this(data: Array[Int]) = this(TWireModel.rectangulate(data)) - override def getUVT = + private val modelPair = WireModel3D.generateModelPair(wireRectangles) + + private def getUVT = if (disabled) new IconTransformation(wireIcons(0)) else if (on) new MultiIconTransformation(wireIcons(0), wireIcons(2)) else new MultiIconTransformation(wireIcons(0), wireIcons(1)) + + override def renderModel(t: Transformation, orient: Int) { + modelPair(if (orient < 24) 0 else 1).render( + new TransformationList(orientPrecomputed(orient), t), + new UVTransformationList(redundantUVTransformation, getUVT), + LightModel.standardLightModel + ) + } } object WireModel3D { @@ -512,6 +522,12 @@ object WireModel3D { model } + private[integration] def generateModelPair( + wireRectangles: Seq[Rectangle4i] + ) = bakeModelPair(generateModel(wireRectangles)) + + private[integration] def bakeModelPair(model: CCModel) = bakeDynamic(model) + def generateWireSegment(model: CCModel, i: Int, rect: Rectangle4i) { generateWireSegment(model, i, TWireModel.border(rect), 0.01, 0) generateWireSegment(model, i + 20, rect, 0.02, 1) diff --git a/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala b/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala index be5cc05bc..79af1e744 100644 --- a/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala +++ b/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala @@ -10,7 +10,7 @@ import javax.imageio.ImageIO import codechicken.lib.colour.{Colour, ColourARGB} import codechicken.lib.render.CCModel import codechicken.lib.vec.Rectangle4i -import org.junit.Assert.{assertEquals, assertNotNull, assertNotSame} +import org.junit.Assert.{assertEquals, assertNotNull, assertNotSame, assertSame} import org.junit.Test import scala.io.Source @@ -57,6 +57,16 @@ class GateWireGoldenTest { assertEquals(originalX, pair(0).verts(0).vec.x, 0) } + @Test + def wireModelPairOwnsGeneratedBase(): Unit = { + val base = WireModel3D.generateModel(GateWireTestData.loadMask("OR-0")) + val pair = WireModel3D.bakeModelPair(base) + + assertSame(base, pair(0)) + assertNotSame(pair(0), pair(1)) + assertNotSame(pair(0).verts(0), pair(1).verts(0)) + } + private def characterize(name: String): String = { val data = GateWireTestData.loadMask(name) val rectangles = TWireModel.rectangulate(data) @@ -72,8 +82,8 @@ class GateWireGoldenTest { } private def characterizeBaking(name: String): String = { - val modelPair = ComponentModelBakery.bakeDynamic( - WireModel3D.generateModel(GateWireTestData.loadMask(name)) + val modelPair = WireModel3D.generateModelPair( + TWireModel.rectangulate(GateWireTestData.loadMask(name)) ) val orientedDigest = digest { out => for (orient <- 0 until 48) { From 52a2a106e8eb0654595a1b8b95f16c239758e553 Mon Sep 17 00:00:00 2001 From: boubou19 Date: Fri, 14 Aug 2026 11:21:08 +0200 Subject: [PATCH 12/14] perf(integration): share reflected wire vertices --- .../projectred/integration/components.scala | 22 ++++++++++++++++++- .../integration/GateWireGoldenTest.scala | 11 +++++++++- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/src/main/scala/mrtjp/projectred/integration/components.scala b/src/main/scala/mrtjp/projectred/integration/components.scala index d1c02382e..88b92707c 100644 --- a/src/main/scala/mrtjp/projectred/integration/components.scala +++ b/src/main/scala/mrtjp/projectred/integration/components.scala @@ -526,7 +526,27 @@ object WireModel3D { wireRectangles: Seq[Rectangle4i] ) = bakeModelPair(generateModel(wireRectangles)) - private[integration] def bakeModelPair(model: CCModel) = bakeDynamic(model) + private[integration] def bakeModelPair(model: CCModel) = + Array(model, reflectedView(model)) + + private def reflectedView(model: CCModel) = { + val reflected = CCModel.quadModel(model.verts.length) + val normals = model.normals() + val reflectedNormals = reflected.getOrAllocate(CCRenderState.normalAttrib()) + var i = 0 + while (i < model.verts.length) { + reflected.verts(i) = model.verts(i) + reflected.verts(i + 1) = model.verts(i + 3) + reflected.verts(i + 2) = model.verts(i + 2) + reflected.verts(i + 3) = model.verts(i + 1) + reflectedNormals(i) = normals(i) + reflectedNormals(i + 1) = normals(i + 3) + reflectedNormals(i + 2) = normals(i + 2) + reflectedNormals(i + 3) = normals(i + 1) + i += 4 + } + reflected + } def generateWireSegment(model: CCModel, i: Int, rect: Rectangle4i) { generateWireSegment(model, i, TWireModel.border(rect), 0.01, 0) diff --git a/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala b/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala index 79af1e744..e6ba3ba47 100644 --- a/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala +++ b/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala @@ -64,7 +64,16 @@ class GateWireGoldenTest { assertSame(base, pair(0)) assertNotSame(pair(0), pair(1)) - assertNotSame(pair(0).verts(0), pair(1).verts(0)) + for (i <- pair(0).verts.indices by 4) { + assertSame(pair(0).verts(i), pair(1).verts(i)) + assertSame(pair(0).verts(i + 3), pair(1).verts(i + 1)) + assertSame(pair(0).verts(i + 2), pair(1).verts(i + 2)) + assertSame(pair(0).verts(i + 1), pair(1).verts(i + 3)) + assertSame(pair(0).normals()(i), pair(1).normals()(i)) + assertSame(pair(0).normals()(i + 3), pair(1).normals()(i + 1)) + assertSame(pair(0).normals()(i + 2), pair(1).normals()(i + 2)) + assertSame(pair(0).normals()(i + 1), pair(1).normals()(i + 3)) + } } private def characterize(name: String): String = { From 9f16c01cde867600bab6b0798b509571f06ec4e2 Mon Sep 17 00:00:00 2001 From: boubou19 Date: Fri, 14 Aug 2026 11:22:55 +0200 Subject: [PATCH 13/14] perf(integration): cache shared wire geometry --- .../projectred/integration/components.scala | 16 +++++++++++++++- .../integration/GateWireGoldenTest.scala | 14 ++++++++++++++ .../integration/GateWireMasksTest.scala | 12 ++++++++++++ 3 files changed, 41 insertions(+), 1 deletion(-) diff --git a/src/main/scala/mrtjp/projectred/integration/components.scala b/src/main/scala/mrtjp/projectred/integration/components.scala index 88b92707c..d096e93b7 100644 --- a/src/main/scala/mrtjp/projectred/integration/components.scala +++ b/src/main/scala/mrtjp/projectred/integration/components.scala @@ -490,7 +490,9 @@ class WireModel3D private[integration] (wireRectangles: Seq[Rectangle4i]) with TWireModel { def this(data: Array[Int]) = this(TWireModel.rectangulate(data)) - private val modelPair = WireModel3D.generateModelPair(wireRectangles) + private[integration] val modelPair = WireModel3D.cachedModelPair( + wireRectangles + ) private def getUVT = if (disabled) new IconTransformation(wireIcons(0)) @@ -507,6 +509,9 @@ class WireModel3D private[integration] (wireRectangles: Seq[Rectangle4i]) } object WireModel3D { + private val modelCache = scala.collection.mutable.HashMap + .empty[Vector[(Int, Int, Int, Int)], Array[CCModel]] + def generateModel(data: Array[Int]): CCModel = generateModel(TWireModel.rectangulate(data)) @@ -526,6 +531,15 @@ object WireModel3D { wireRectangles: Seq[Rectangle4i] ) = bakeModelPair(generateModel(wireRectangles)) + private[integration] def cachedModelPair( + wireRectangles: Seq[Rectangle4i] + ) = { + val key = wireRectangles.map(r => (r.x, r.y, r.w, r.h)).toVector + modelCache.synchronized { + modelCache.getOrElseUpdate(key, generateModelPair(wireRectangles)) + } + } + private[integration] def bakeModelPair(model: CCModel) = Array(model, reflectedView(model)) diff --git a/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala b/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala index e6ba3ba47..10c5fc5ae 100644 --- a/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala +++ b/src/test/scala/mrtjp/projectred/integration/GateWireGoldenTest.scala @@ -76,6 +76,20 @@ class GateWireGoldenTest { } } + @Test + def equivalentWireLayoutsShareOnlyGeometry(): Unit = { + val rectangles = GateWireMasks.rectangles("OR-0") + val equivalent = rectangles.map(r => new Rectangle4i(r.x, r.y, r.w, r.h)) + val first = new WireModel3D(rectangles) + val second = new WireModel3D(equivalent) + + assertSame(first.modelPair, second.modelPair) + first.on = true + first.disabled = true + assertEquals(false, second.on) + assertEquals(false, second.disabled) + } + private def characterize(name: String): String = { val data = GateWireTestData.loadMask(name) val rectangles = TWireModel.rectangulate(data) diff --git a/src/test/scala/mrtjp/projectred/integration/GateWireMasksTest.scala b/src/test/scala/mrtjp/projectred/integration/GateWireMasksTest.scala index 5ec662759..547a6e678 100644 --- a/src/test/scala/mrtjp/projectred/integration/GateWireMasksTest.scala +++ b/src/test/scala/mrtjp/projectred/integration/GateWireMasksTest.scala @@ -24,6 +24,18 @@ class GateWireMasksTest { } } + @Test + def builtInMasksHaveExpectedUniqueLayouts(): Unit = { + assertEquals( + 97, + GateWireGoldenTest.maskNames + .map(GateWireMasks.rectangles) + .map(tuples) + .distinct + .size + ) + } + @Test def builtInLayoutClosesButDoesNotReadSingleResource(): Unit = { val stream = From 2fd131b0da7c3896e37278845c38a5bb620d3c48 Mon Sep 17 00:00:00 2001 From: boubou19 Date: Fri, 14 Aug 2026 11:37:43 +0200 Subject: [PATCH 14/14] perf(integration): cache torch geometry --- .../projectred/integration/components.scala | 40 +++++++++++---- .../ComponentModelSharingTest.scala | 49 +++++++++++++++++++ 2 files changed, 79 insertions(+), 10 deletions(-) create mode 100644 src/test/scala/mrtjp/projectred/integration/ComponentModelSharingTest.scala diff --git a/src/main/scala/mrtjp/projectred/integration/components.scala b/src/main/scala/mrtjp/projectred/integration/components.scala index d096e93b7..eee3ce647 100644 --- a/src/main/scala/mrtjp/projectred/integration/components.scala +++ b/src/main/scala/mrtjp/projectred/integration/components.scala @@ -290,13 +290,13 @@ abstract class ComponentModel { def registerIcons(reg: IIconRegister) {} } -abstract class SingleComponentModel(m: CCModel, pos: Vector3 = Vector3.zero) - extends ComponentModel { - // instead of creating 48 models, only make 2 (original + flipped orientation) - private val modelPair = { - val t = pos.copy.multiply(1 / 16d).translation - bakeDynamic(m.copy.apply(t)) - } +abstract class SingleComponentModel private[integration] ( + private[integration] val modelPair: Array[CCModel] +) extends ComponentModel { + def this(m: CCModel, pos: Vector3) = + this(SingleComponentModel.bakeModelPair(m, pos)) + + def this(m: CCModel) = this(m, Vector3.zero) def extraTransformModel(orient: Int): Transformation = orientPrecomputed( orient @@ -316,6 +316,13 @@ abstract class SingleComponentModel(m: CCModel, pos: Vector3 = Vector3.zero) } } +private[integration] object SingleComponentModel { + def bakeModelPair(m: CCModel, pos: Vector3) = { + val t = pos.copy.multiply(1 / 16d).translation + bakeDynamic(m.copy.apply(t)) + } +} + abstract class MultiComponentModel(m: Seq[CCModel], pos: Vector3 = Vector3.zero) extends ComponentModel { val models = { @@ -336,8 +343,13 @@ abstract class MultiComponentModel(m: Seq[CCModel], pos: Vector3 = Vector3.zero) } } -abstract class OnOffModel(m: CCModel, pos: Vector3 = Vector3.zero) - extends SingleComponentModel(m, pos) { +abstract class OnOffModel private[integration] (modelPair: Array[CCModel]) + extends SingleComponentModel(modelPair) { + def this(m: CCModel, pos: Vector3) = + this(SingleComponentModel.bakeModelPair(m, pos)) + + def this(m: CCModel) = this(m, Vector3.zero) + var on = false def getIcons: Array[IIcon] @@ -641,7 +653,7 @@ trait TRedstoneTorchModel extends OnOffModel { } class RedstoneTorchModel(x: Double, z: Double, h: Int) - extends OnOffModel(RedstoneTorchModel.genModel(x, z, h)) + extends OnOffModel(RedstoneTorchModel.cachedModelPair(x, z, h)) with TRedstoneTorchModel { override val getLightPos = new Vector3(x, h - 1, z).multiply(1 / 16d) @@ -665,6 +677,14 @@ class FlippedRSTorchModel(x: Double, z: Double) } object RedstoneTorchModel { + private val modelCache = scala.collection.mutable.HashMap + .empty[(Double, Double, Int), Array[CCModel]] + + private[integration] def cachedModelPair(x: Double, z: Double, h: Int) = + modelCache.synchronized { + modelCache.getOrElseUpdate((x, z, h), bakeDynamic(genModel(x, z, h))) + } + def genModel(x: Double, z: Double, h: Int) = { val m = CCModel.quadModel(20) m.verts(0) = new Vertex5(7 / 16d, 10 / 16d, 9 / 16d, 7 / 16d, 8 / 16d) diff --git a/src/test/scala/mrtjp/projectred/integration/ComponentModelSharingTest.scala b/src/test/scala/mrtjp/projectred/integration/ComponentModelSharingTest.scala new file mode 100644 index 000000000..725f802bb --- /dev/null +++ b/src/test/scala/mrtjp/projectred/integration/ComponentModelSharingTest.scala @@ -0,0 +1,49 @@ +package mrtjp.projectred.integration + +import codechicken.lib.render.CCModel +import codechicken.lib.vec.Vector3 +import org.junit.Assert.{assertEquals, assertNotSame, assertSame} +import org.junit.Test + +class ComponentModelSharingTest { + + @Test + def equivalentTorchesShareOnlyGeometry(): Unit = { + val first = new RedstoneTorchModel(8, 8, 6) + val second = new RedstoneTorchModel(8, 8, 6) + val different = new RedstoneTorchModel(8, 8, 8) + val expected = SingleComponentModel.bakeModelPair( + RedstoneTorchModel.genModel(8, 8, 6), + Vector3.zero + ) + + assertSame(first.modelPair, second.modelPair) + assertNotSame(first.modelPair, different.modelPair) + assertModelsEqual(expected, first.modelPair) + first.on = true + assertEquals(false, second.on) + } + + private def assertModelsEqual( + expected: Array[CCModel], + actual: Array[CCModel] + ) = + for ( + pairIndex <- expected.indices; + vertexIndex <- expected(pairIndex).verts.indices + ) { + val expectedVertex = expected(pairIndex).verts(vertexIndex) + val actualVertex = actual(pairIndex).verts(vertexIndex) + val expectedNormal = expected(pairIndex).normals()(vertexIndex) + val actualNormal = actual(pairIndex).normals()(vertexIndex) + assertEquals(expectedVertex.vec.x, actualVertex.vec.x, 0) + assertEquals(expectedVertex.vec.y, actualVertex.vec.y, 0) + assertEquals(expectedVertex.vec.z, actualVertex.vec.z, 0) + assertEquals(expectedVertex.uv.u, actualVertex.uv.u, 0) + assertEquals(expectedVertex.uv.v, actualVertex.uv.v, 0) + assertEquals(expectedVertex.uv.tex, actualVertex.uv.tex) + assertEquals(expectedNormal.x, actualNormal.x, 0) + assertEquals(expectedNormal.y, actualNormal.y, 0) + assertEquals(expectedNormal.z, actualNormal.z, 0) + } +}