From e7e3c77e3ff0267419f8d64f596a83169dd97f4d Mon Sep 17 00:00:00 2001 From: Seth Morabito Date: Fri, 27 Dec 2013 21:40:28 -0800 Subject: [PATCH] First pass at Video window --- pom.xml | 19 +- src/main/java/com/loomcom/symon/Cpu.java | 27 +- .../java/com/loomcom/symon/Simulator.java | 59 +++- .../java/com/loomcom/symon/devices/Crtc.java | 101 +++++++ .../com/loomcom/symon/devices/Memory.java | 3 + .../java/com/loomcom/symon/ui/CrtPanel.java | 275 ++++++++++++++++++ .../com/loomcom/symon/ui/VideoWindow.java | 39 +++ src/main/resources/cga8.rom | Bin 0 -> 4096 bytes src/main/resources/pet.rom | Bin 0 -> 2048 bytes 9 files changed, 496 insertions(+), 27 deletions(-) create mode 100644 src/main/java/com/loomcom/symon/devices/Crtc.java create mode 100644 src/main/java/com/loomcom/symon/ui/CrtPanel.java create mode 100644 src/main/java/com/loomcom/symon/ui/VideoWindow.java create mode 100644 src/main/resources/cga8.rom create mode 100644 src/main/resources/pet.rom diff --git a/pom.xml b/pom.xml index c25f237..127ff87 100644 --- a/pom.xml +++ b/pom.xml @@ -130,7 +130,7 @@ org.codehaus.mojo cobertura-maven-plugin - 2.4 + 2.6 false @@ -167,6 +167,21 @@ + + org.codehaus.mojo + exec-maven-plugin + 1.2.1 + + com.loomcom.symon.Simulator + + + + + java + + + + @@ -175,7 +190,7 @@ org.codehaus.mojo cobertura-maven-plugin - 2.4 + 2.6 diff --git a/src/main/java/com/loomcom/symon/Cpu.java b/src/main/java/com/loomcom/symon/Cpu.java index db7f944..4b19002 100644 --- a/src/main/java/com/loomcom/symon/Cpu.java +++ b/src/main/java/com/loomcom/symon/Cpu.java @@ -161,10 +161,8 @@ public class Cpu implements InstructionTable { irAddressMode = (state.ir >> 2) & 0x07; irOpMode = state.ir & 0x03; - // Increment PC incrementPC(); - // Clear the illegal opcode trap. clearOpTrap(); // Decode the instruction and operands @@ -175,7 +173,6 @@ public class Cpu implements InstructionTable { incrementPC(); } - // Increment step counter state.stepCounter++; // Get the data from the effective address (if any) @@ -412,18 +409,17 @@ public class Cpu implements InstructionTable { } state.pc = address(bus.read(lo), bus.read(hi)); - /* TODO: For accuracy, allow a flag to enable broken behavior - * of early 6502s: - * - * "An original 6502 has does not correctly fetch the target - * address if the indirect vector falls on a page boundary - * (e.g. $xxFF where xx is and value from $00 to $FF). In this - * case fetches the LSB from $xxFF as expected but takes the MSB - * from $xx00. This is fixed in some later chips like the 65SC02 - * so for compatibility always ensure the indirect vector is not - * at the end of the page." - * (http://www.obelisk.demon.co.uk/6502/reference.html#JMP) - */ + /* TODO: For accuracy, allow a flag to enable broken behavior of early 6502s: + * + * "An original 6502 has does not correctly fetch the target + * address if the indirect vector falls on a page boundary + * (e.g. $xxFF where xx is and value from $00 to $FF). In this + * case fetches the LSB from $xxFF as expected but takes the MSB + * from $xx00. This is fixed in some later chips like the 65SC02 + * so for compatibility always ensure the indirect vector is not + * at the end of the page." + * (http://www.obelisk.demon.co.uk/6502/reference.html#JMP) + */ break; @@ -724,6 +720,7 @@ public class Cpu implements InstructionTable { break; /** Unimplemented Instructions ****************************************/ + // TODO: Create a flag to enable highly-accurate emulation of unimplemented instructions. default: setOpTrap(); break; diff --git a/src/main/java/com/loomcom/symon/Simulator.java b/src/main/java/com/loomcom/symon/Simulator.java index 3f5022d..dd4844a 100644 --- a/src/main/java/com/loomcom/symon/Simulator.java +++ b/src/main/java/com/loomcom/symon/Simulator.java @@ -24,6 +24,7 @@ package com.loomcom.symon; import com.loomcom.symon.devices.Acia; +import com.loomcom.symon.devices.Crtc; import com.loomcom.symon.devices.Memory; import com.loomcom.symon.devices.Via; import com.loomcom.symon.exceptions.FifoUnderrunException; @@ -37,11 +38,7 @@ import javax.swing.*; import javax.swing.border.EmptyBorder; import java.awt.*; import java.awt.event.*; -import java.beans.PropertyChangeEvent; -import java.beans.PropertyChangeListener; import java.io.*; -import java.util.Observable; -import java.util.Observer; import java.util.logging.Level; import java.util.logging.Logger; @@ -69,6 +66,10 @@ public class Simulator { // ACIA at $8800-$8803 private static final int ACIA_BASE = 0x8800; + // CRTC at $9000-$9001 + private static final int CRTC_BASE = 0x9000; + private static final int VIDEO_RAM_BASE = 0x7000; + // 16KB ROM at $C000-$FFFF private static final int ROM_BASE = 0xC000; private static final int ROM_SIZE = 0x4000; @@ -96,12 +97,18 @@ public class Simulator { private final Cpu cpu; private final Acia acia; private final Via via; + private final Crtc crtc; private final Memory ram; private Memory rom; + // Number of CPU steps between CRT repaints. + // TODO: Dynamically refresh the value at runtime based on performance figures to reach ~ 30fps. + private long stepsBetweenCrtcRefreshes = 2500; + // A counter to keep track of the number of UI updates that have been // requested private int stepsSinceLastUpdate = 0; + private int stepsSinceLastCrtcRefresh = 0; // The number of steps to run per click of the "Step" button private int stepsPerClick = 1; @@ -123,10 +130,7 @@ public class Simulator { */ private MemoryWindow memoryWindow; - /** - * The Zero Page Window shows the contents of page 0. - */ - private JFrame zeroPageWindow; + private VideoWindow videoWindow; private SimulatorMenu menuBar; @@ -153,6 +157,7 @@ public class Simulator { this.bus = new Bus(BUS_BOTTOM, BUS_TOP); this.cpu = new Cpu(); this.ram = new Memory(MEMORY_BASE, MEMORY_SIZE, false); + this.crtc = new Crtc(CRTC_BASE, ram, VIDEO_RAM_BASE); bus.addCpu(cpu); bus.addDevice(ram); @@ -259,6 +264,9 @@ public class Simulator { // Prepare the memory window memoryWindow = new MemoryWindow(bus); + // Prepare the video window + videoWindow = new VideoWindow(crtc.getCrtPanel()); + mainWindow.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); // The Menu. This comes last, because it relies on other components having @@ -266,10 +274,10 @@ public class Simulator { menuBar = new SimulatorMenu(); mainWindow.setJMenuBar(menuBar); - console.requestFocus(); - mainWindow.pack(); mainWindow.setVisible(true); + + console.requestFocus(); } private void handleStart() { @@ -365,6 +373,11 @@ public class Simulator { logger.severe("Console type-ahead buffer underrun!"); } + if (stepsSinceLastCrtcRefresh++ > stepsBetweenCrtcRefreshes) { + videoWindow.refreshDisplay(); + stepsSinceLastCrtcRefresh = 0; + } + // This is a very expensive update, and we're doing it without // a delay, so we don't want to overwhelm the Swing event processing thread // with requests. Limit the number of ui updates that can be performed. @@ -671,6 +684,23 @@ public class Simulator { } } + class ToggleVideoWindowAction extends AbstractAction { + public ToggleVideoWindowAction() { + super("Video Window", null); + putValue(SHORT_DESCRIPTION, "Show or Hide the Video Window"); + } + + public void actionPerformed(ActionEvent actionEvent) { + synchronized (videoWindow) { + if (videoWindow.isVisible()) { + videoWindow.setVisible(false); + } else { + videoWindow.setVisible(true); + } + } + } + } + class SimulatorMenu extends JMenuBar { // Menu Items private JMenuItem loadProgramItem; @@ -758,6 +788,15 @@ public class Simulator { }); viewMenu.add(showMemoryTable); + final JCheckBoxMenuItem showVideoWindow = new JCheckBoxMenuItem(new ToggleVideoWindowAction()); + videoWindow.addWindowListener(new WindowAdapter() { + @Override + public void windowClosing(WindowEvent e) { + showVideoWindow.setSelected(false); + } + }); + viewMenu.add(showVideoWindow); + add(viewMenu); } diff --git a/src/main/java/com/loomcom/symon/devices/Crtc.java b/src/main/java/com/loomcom/symon/devices/Crtc.java new file mode 100644 index 0000000..95cee0b --- /dev/null +++ b/src/main/java/com/loomcom/symon/devices/Crtc.java @@ -0,0 +1,101 @@ +package com.loomcom.symon.devices; + +import com.loomcom.symon.exceptions.MemoryAccessException; +import com.loomcom.symon.exceptions.MemoryRangeException; +import com.loomcom.symon.ui.CrtPanel; + +import java.io.BufferedInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; + + +/** + * Simulation of a 6545 CRTC and virtual CRT output. + */ +public class Crtc extends Device { + + public static final int CHAR_WIDTH = 8; + public static final int CHAR_HEIGHT = 8; + public static final int SCAN_LINES = 9; + public static final int COLUMNS = 40; + public static final int ROWS = 25; + public static final int SCALE = 2; + + public static final int REGISTER_SELECT = 0; + public static final int REGISTER_WRITE = 1; + + public static String CHAR_ROM_RESOURCE = "/pet.rom"; + + private CrtPanel crtPanel; + private int currentRegister = 0; + + public Crtc(int deviceAddress, Memory memory, int videoRamStartAddress) throws MemoryRangeException, IOException { + super(deviceAddress, 2, "CRTC"); + this.crtPanel = new CrtPanel(loadCharRom(CHAR_ROM_RESOURCE), memory.getDmaAccess(), COLUMNS, ROWS, + CHAR_WIDTH, CHAR_HEIGHT, + SCALE, SCALE, videoRamStartAddress); + } + + private byte[] loadCharRom(String resource) throws IOException { + BufferedInputStream bis = null; + try { + bis = new BufferedInputStream(this.getClass().getResourceAsStream(resource)); + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + while (bis.available() > 0) { + bos.write(bis.read()); + } + bos.flush(); + bos.close(); + return bos.toByteArray(); + } finally { + if (bis != null) { + bis.close(); + } + } + } + + public CrtPanel getCrtPanel() { + return crtPanel; + } + + @Override + public void write(int address, int data) throws MemoryAccessException { + switch (address) { + case REGISTER_SELECT: + setCurrentRegister(data); + case REGISTER_WRITE: + writeRegisterValue(data); + default: + throw new MemoryAccessException("No such address."); + } + } + + @Override + public int read(int address) throws MemoryAccessException { + switch (address) { + case REGISTER_SELECT: + return status(); + case REGISTER_WRITE: + return 0; + default: + throw new MemoryAccessException("No such address."); + } + } + + @Override + public String toString() { + return null; + } + + private int status() { + return 0; + } + + private void setCurrentRegister(int registerNumber) { + this.currentRegister = registerNumber; + } + + private void writeRegisterValue(int data) { + + } +} diff --git a/src/main/java/com/loomcom/symon/devices/Memory.java b/src/main/java/com/loomcom/symon/devices/Memory.java index 8272652..32c65ae 100644 --- a/src/main/java/com/loomcom/symon/devices/Memory.java +++ b/src/main/java/com/loomcom/symon/devices/Memory.java @@ -109,4 +109,7 @@ public class Memory extends Device { return "Memory: " + getMemoryRange().toString(); } + public int[] getDmaAccess() { + return mem; + } } \ No newline at end of file diff --git a/src/main/java/com/loomcom/symon/ui/CrtPanel.java b/src/main/java/com/loomcom/symon/ui/CrtPanel.java new file mode 100644 index 0000000..a54a97a --- /dev/null +++ b/src/main/java/com/loomcom/symon/ui/CrtPanel.java @@ -0,0 +1,275 @@ +package com.loomcom.symon.ui; + +import javax.swing.*; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.util.concurrent.*; + +/** + * Simulates a CRT Display backed by a 6545 CRTC. + */ +public class CrtPanel extends JPanel { + + // Character width and height are hardware-implementation specific + // and cannot be modified at runtime. + private final int charWidth; + private final int charHeight; + private final int scaleX, scaleY; + private final boolean shouldScale; + + private Dimension dimensions; + private BufferedImage image; + + private int[] charRom; + private int[] videoRam; + + /* Fields corresponding to internal registers in the MOS/Rockwell 6545 */ + + // R1 - Horizontal Displayed + private final int horizontalDisplayed; + // R6 - Vertical Displayed + private final int verticalDisplayed; + // R9 - Scan Lines: Number of scan lines per character, including spacing. + private int scanLinesPerRow = 9; + // R10 - Cursor Start + private int cursorStartLine; + // R11 - Cursor End + private int cursorStopLine; + private boolean cursorEnabled; + private int cursorBlinkDelay; + private boolean cursorBlinkEnabled; + // R12, R13 - Display Start Address: The starting address in the video RAM of the displayed page. + private int startAddress; + // R14, R15 - Cursor Position + private int cursorPosition; + + // The size, in bytes, of a displayed page of characters. + private int pageSize; + + private ScheduledExecutorService scheduler; + private ScheduledFuture cursorBlinker; + + private class CursorBlinker implements Runnable { + public void run() { + SwingUtilities.invokeLater(new Runnable() { + public void run() { + if (cursorBlinkEnabled) { + cursorEnabled = !cursorEnabled; + repaint(); + } + } + }); + } + } + + public CrtPanel(byte[] charRom, int[] videoRam, + int horizontalDisplayed, int verticalDisplayed, + int charWidth, int charHeight, + int scaleX, int scaleY, + int startAddress) { + super(); + + this.charRom = convertCharRom(charRom, charWidth); + this.videoRam = videoRam; + this.horizontalDisplayed = horizontalDisplayed; + this.verticalDisplayed = verticalDisplayed; + this.pageSize = horizontalDisplayed * verticalDisplayed; + this.scaleX = scaleX; + this.scaleY = scaleY; + this.startAddress = startAddress; + this.charWidth = charWidth; + this.charHeight = charHeight; + this.scanLinesPerRow = charHeight + 1; + this.cursorStartLine = 0; + this.cursorStopLine = charHeight - 1; + this.cursorBlinkEnabled = true; + this.cursorBlinkDelay = 500; // ms + + this.shouldScale = (this.scaleX > 1 || this.scaleY > 1); + + buildImage(); + + scheduler = Executors.newSingleThreadScheduledExecutor(); + cursorBlinker = scheduler.scheduleAtFixedRate(new CursorBlinker(), cursorBlinkDelay, cursorBlinkDelay, TimeUnit.MILLISECONDS); + } + + @Override + public void paintComponent(Graphics g) { + for (int i = 0; i < pageSize; i++) { + int address = startAddress + i; + int originX = (i % horizontalDisplayed) * charWidth; + int originY = (i / horizontalDisplayed) * scanLinesPerRow; + image.getRaster().setPixels(originX, originY, charWidth, scanLinesPerRow, getGlyph(i, videoRam[address])); + } + Graphics2D g2d = (Graphics2D)g; + if (shouldScale) { + g2d.scale(scaleX, scaleY); + } + g2d.drawImage(image, 0, 0, null); + } + + public void setStartAddress(int address) { + startAddress = address; + repaint(); + } + + public int getStartAddress() { + return startAddress; + } + + /** + * Returns an array of pixels (including extra scanlines, if any) corresponding to the + * Character ROM plus cursor overlay (if any). The cursor overlay simulates an XOR + * of the Character Rom output and the 6545 Cursor output. + * + * @param position The position within the character field, from 0 to (horizontalDisplayed * verticalDisplayed) + * @param chr The character value within the ROM to display. + * @return + */ + private int[] getGlyph(int position, int chr) { + int romOffset = (chr & 0xff) * (charHeight * charWidth); + int[] glyph = new int[charWidth * scanLinesPerRow]; + + // Populate the character + for (int i = 0; i < (charWidth * Math.min(charHeight, scanLinesPerRow)); i++) { + glyph[i] = charRom[romOffset + i]; + } + + // Overlay the cursor + if (cursorEnabled && cursorPosition == position) { + int cursorStart = Math.min(glyph.length, cursorStartLine * charWidth); + int cursorStop = Math.min(glyph.length, (cursorStopLine + 1) * charWidth); + + for (int i = cursorStart; i < cursorStop; i++) { + glyph[i] ^= 0xff; + } + } + + return glyph; + } + + @Override + public int getWidth() { + return (int) dimensions.getWidth(); + } + + @Override + public int getHeight() { + return (int) dimensions.getHeight(); + } + + @Override + public Dimension getPreferredSize() { + return dimensions; + } + + @Override + public Dimension getMaximumSize() { + return dimensions; + } + + @Override + public Dimension getMinimumSize() { + return dimensions; + } + + public int getCursorStartLine() { + return cursorStartLine; + } + + public void setCursorStartLine(int cursorStartLine) { + this.cursorStartLine = cursorStartLine; + } + + public int getCursorStopLine() { + return cursorStopLine; + } + + public void setCursorStopLine(int cursorStopLine) { + this.cursorStopLine = cursorStopLine; + } + + public int getCursorBlinkDelay() { + return cursorBlinkDelay; + } + + public void setCursorBlinkDelay(int cursorBlinkDelay) { + this.cursorBlinkDelay = cursorBlinkDelay; + } + + public int getCursorPosition() { + return cursorPosition; + } + + public void setCursorPosition(int cursorPosition) { + this.cursorPosition = cursorPosition; + } + + public boolean isCursorEnabled() { + return cursorEnabled; + } + + public void setCursorEnabled(boolean cursorEnabled) { + this.cursorEnabled = cursorEnabled; + } + + public boolean isCursorBlinkEnabled() { + return cursorBlinkEnabled; + } + + public void setCursorBlinkEnabled(boolean cursorBlinkEnabled) { + if (cursorBlinkEnabled && cursorBlinker == null) { + cursorBlinker = scheduler.scheduleAtFixedRate(new CursorBlinker(), + cursorBlinkDelay, + cursorBlinkDelay, + TimeUnit.MILLISECONDS); + } else if (!cursorBlinkEnabled && cursorBlinker != null) { + cursorBlinker.cancel(true); + cursorBlinker = null; + } + + this.cursorBlinkEnabled = cursorBlinkEnabled; + repaint(); + } + + public void setScanLinesPerRow(int scanLinesPerRow) { + this.scanLinesPerRow = scanLinesPerRow; + buildImage(); + } + + public int getScanLinesPerRow() { + return scanLinesPerRow; + } + + private void buildImage() { + int rasterWidth = charWidth * horizontalDisplayed; + int rasterHeight = scanLinesPerRow * verticalDisplayed; + + this.image = new BufferedImage(rasterWidth, rasterHeight, BufferedImage.TYPE_BYTE_BINARY); + this.dimensions = new Dimension(rasterWidth * scaleX, rasterHeight * scaleY); + } + + + /** + * Convert a raw binary Character ROM image into an array of pixel data usable + * by the Raster underlying the display's BufferedImage. + * + * @param rawBytes + * @param charWidth + * @return + */ + private int[] convertCharRom(byte[] rawBytes, int charWidth) { + int[] converted = new int[rawBytes.length * charWidth]; + + int romIndex = 0; + for (int i = 0; i < converted.length;) { + byte charRow = rawBytes[romIndex++]; + + for (int j = 7; j >= 0; j--) { + converted[i++] = ((charRow & (1 << j)) == 0) ? 0 : 0xff; + } + } + return converted; + } + +} diff --git a/src/main/java/com/loomcom/symon/ui/VideoWindow.java b/src/main/java/com/loomcom/symon/ui/VideoWindow.java new file mode 100644 index 0000000..edea06f --- /dev/null +++ b/src/main/java/com/loomcom/symon/ui/VideoWindow.java @@ -0,0 +1,39 @@ +package com.loomcom.symon.ui; + +import javax.swing.*; +import java.awt.*; + +public class VideoWindow extends JFrame { + + private CrtPanel crtPanel; + + public VideoWindow(CrtPanel crtPanel) { + this.crtPanel = crtPanel; + createUi(); + } + + public void createUi() { + setTitle("Composite Video"); + + int borderWidth = (int) (crtPanel.getWidth() * 0.08); + int borderHeight = (int) (crtPanel.getHeight() * 0.08); + + JPanel containerPane = new JPanel(); + containerPane.setBorder(BorderFactory.createEmptyBorder(borderHeight, borderWidth, borderHeight, borderWidth)); + containerPane.setLayout(new BorderLayout()); + containerPane.setBackground(Color.black); + containerPane.add(crtPanel, BorderLayout.CENTER); + + getContentPane().add(containerPane); + setResizable(false); + pack(); + } + + public void refreshDisplay() { + // TODO: Verify whether this is necessary. Does `repaint()' do anything if the window is not visible? + if (isVisible()) { + repaint(); + } + } + +} diff --git a/src/main/resources/cga8.rom b/src/main/resources/cga8.rom new file mode 100644 index 0000000000000000000000000000000000000000..71f2c04a9f76cdbe31f3b3948cc51433d1706ea9 GIT binary patch literal 4096 zcmb_f&ubj_6@RR^>Kq1X5P}S7@s77kP)i73OGX}&>1sXQs*(_GF-}h<9ad(N!$3t2 z>d;PRW#fYjIha78{sVgHxmUx0!ZwG&xfz3m&`G`oql1x`FwFMz`Oa+XKteBltC{zG z@B6;**Y~~ecO+6ldhhS|o;<#nrqko;(Tiysm1UW@mRN40v75MM`OI=H&9Gb%+w*LZ z>GZ`BXEx!j&I=`ryHX*z-J!o?o23cALC45yDMs?yC?e&0PFTX*uDjj;q9GG%F~|TwY5D5 z`ryC6<6Y78O7|1c$UEX1Z6mLUAJYI&sHC%G5N~g9uM3H#&Ml28H)39{jU?(U7| zJn0Te=^+70RYr>@Xsp_N54egSSjUfoTR}|KJp2(!u;q!EmK9i*iS@cSL>b1MVJKPM zg(!hpyEUq!G#j1_WH1=?!~}-T$xVRxN<$4`)@L^tGQHy-?E@XPboBFRUAS;u46{-G%_2ei`}j6 zBBb!{Zi#drR=*GaQT>kpbo`PJg=cV~2NxFc65tgc8Y(oN@n1E`p8;lT`c<=lEcwJ1 zv|Mm8pwequZH#GRs=2x;NLprm^KJ~rD*V0>hy-3@t=+|1Ft6tahVUbnQ=)^=f!Oz5Ba)2`>I!1Su}3z2C#N`hMhSsY%}uuube zmH73Y!OnHId*Ub9!PM^qL^kfw3P{Ixd~CS%wzjqu+HElSbH9HmYr8y9B(YctR-(%q z^{30Q8{(X{t2!Ef@z*bgg5%!Xx$X52P0R5d^d%+-wmG5si28*GNm|g z7Mb)49z6=sH@N(8AE7YW%St)Yl-j>I8|{tu_CEUG_dk|}p@cFzKK{${@-pxLDEglJUwg@i<0=@IosTQ9lLB+9Bq9lqU&5EKDzerS)#pVS2KF)0wj>V1KgOmI~ zf*{j!whDqk*@DN$SS0kOUWh5B3Mi~VaYERB6<}h{gmiW&$@uB%<#{4%UjYhz^!(GXC)i^w%LUm3#B$Yy zoABp6jN&LfmkXq47?v`AiOga0pdR)ooBG$GQkF)bb&m%QGk#xKp!yUld~W?2;r}ieI36kRRYceCg5Ci>6b{( z1OQJMf!4=7iJTzgRH*e`1$_`|{krh*U6?3!gZsy2^aBX-qadG^C2JK4&>?{jf3VMg zQ+Eq#DRrh)i+!OV5@5*zLnAN|W8{fZ+GZ>{6{mtPJ{@OzHl+)`Vr*U5ABN@AJ!NdA0KVz6JvH!YsiyZcfAP(3XBXg9Gq=$0P^QX{) zKjvqKsTef)+D|B~FloenWP3blsxTmj(;&<6uY?=jy$H@(H*vv^ME1WOe?9)!{Ic!Y z_VazjtK)HN|8xRqw^i$(=nX^j=HC=2xp+}j-Tyd^a9m^G(O{HE;kC{q%EHXaI+PrBl_HJ|!!^0j_bZBR%yR7JmW|JU>6 z&+h;Ab=?1;X^na*QrQJPvqvU?dg(=*>ZP*F(MC1BJ_V*%Gk<4s(QjPu8Ys`f58wX> zCtsbQui|`?Pq2eUj)N~l`fwBA{4a10@jS*w{rvg!eVp%rB*@T6Oo!MaF~k5iF3lQ_tDerJKpVkV$k^bh@j;=~Zg6;9_u@W#nh$o%p$?Nk5< qv=GmDG%|acw<3%Xs?Fj6&jOC2Zz8W1>nKF_p^)C-L+3m9|GxoEA7o$v literal 0 HcmV?d00001 diff --git a/src/main/resources/pet.rom b/src/main/resources/pet.rom new file mode 100644 index 0000000000000000000000000000000000000000..0af9d263ef81611bbd6186f8301093f9f101ef78 GIT binary patch literal 2048 zcmeHH!D`z;5FN{+S&B(S$U&4P6+)Ddlfe*-5$bjk!ZoFY()M6-2&R}Gb83AslJgn4 z_1v$>2lylW2|4Cas=l|94yA?AQ=#<9(s*Wf=gsWwtd3{zUq#0fCbqQJ$~unII9`j6 zjWMy7RT9T>vXau6l`&;24aZ+wjAM;krxl@P-v{!s*nE05&8@%-P>Nm7=k#wEZ)OD~ z_^eGrSzEit1gFoECr;F!P_(VTWF@5nt1D{e+&w8lgY&p?1#h4VYm++wZr!+96TC?SB@u; zCWo4{byih66J+B66V1Pf&g8ZiAGfXe(7xjyDCPI@A2QfMZW`2^rVhPcllP|X26_!7 zEdkWWp_a3%sxqyFr-z`q?<;kUdZXaiOqhA2oS#up@meqsu45oI-Y@6PL!A)mWp9s; zq^_PnJ=p8_934J)EvVN-H_*r~+|Ac_&C`bM*3(jd|8{@3yWR9#kn6zli5vh=-DQq!udTCM_90qF~5a^Sln0Tq1kN#5vKlaew&0eN9_;^{(Q?c8ulK)) OzPmIoZ07S&|Nj6i277k^ literal 0 HcmV?d00001