12 |
13 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2015 Dan Sanderson
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
23 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # p8advent
2 |
3 | This will eventually be an adventure game toolkit for the [Pico-8 virtual
4 | console](http://www.lexaloffle.com/pico-8.php). Right now it's just some data
5 | format and workflow experiments. :)
6 |
7 | Currently the only thing here are some experiments with packing text strings
8 | into cart data. The `p8advent` tool (see `tool.py`) uses a specially-marked
9 | Lua source file to create a Pico-8 cart with string literals extracted into
10 | the cart data region, packed using a given text packing library.
11 |
12 | `textlib` was an early attempt at a dictionary-based packing library that
13 | focused on English words, inspired by methods used by old text adventure
14 | games. It stores both the code stream and the dictionary in cart data, and so
15 | requires minimal RAM to access strings. In the context of Pico-8, it's not
16 | very satisfying: the compression rate for a long wordy text (_A Tale of Two
17 | Cities_ by Charles Dickens) only compressed to about 75% the original size.
18 | Simply packing the 6-bit character set into 8-bit strings (a reasonable
19 | method not yet implemented here) would be as effective, so a fancier
20 | algorithm has to do better than this.
21 |
22 | `lzwlib` uses the LZW compression algorithm with variable-width codes. All
23 | strings share the same dictionary to maximize packing, but are stored
24 | byte-aligned with headers so they can be accessed directly. This requires
25 | that the Lua code reconstruct the dictionary in RAM before accessing any
26 | strings. The Dickens test text compresses to 48% when using as much Lua RAM
27 | as possible for the largest possible dictionary. Capping the dictionary at
28 | 4,096 entries bumps this up to about 60% for this text.
29 |
30 | Of course, ToTC is not a typical text corpus for a game, even a large
31 | text-based game. I'll need to make an actual game that uses a lot of text to
32 | demonstrate that fancy packing techniques are actually profitable. It seems
33 | likely that a game that uses both text and graphics and wants to store
34 | strings could simply use a bit stream of 6-bit characters.
35 |
--------------------------------------------------------------------------------
/advent:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python3
2 |
3 | import sys
4 | sys.path.append('../picotool')
5 |
6 | from p8advent import tool
7 |
8 | if __name__ == '__main__':
9 | sys.exit(tool.main(sys.argv[1:]))
10 |
--------------------------------------------------------------------------------
/game.lua:
--------------------------------------------------------------------------------
1 | function _update()
2 | end
3 |
4 | function _draw()
5 | end
6 |
--------------------------------------------------------------------------------
/p8advent/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/dansanderson/p8advent/9257a4aebc88840da8e0d4af5172f60ea0576604/p8advent/__init__.py
--------------------------------------------------------------------------------
/p8advent/lzwlib.py:
--------------------------------------------------------------------------------
1 | """An LZW-based string packer.
2 |
3 | LzwLib stores a set of strings as compressed binary data. It can also
4 | generate Pico-8 Lua code capable of accessing a string given a string's ID (
5 | returned by the encoder method). The goal is to make it easy to write Pico-8
6 | games that use a large quantity of English text without storing that text in
7 | the code region of the cart.
8 |
9 | All strings added to the data structure contribute to a single decoder
10 | dictionary. The Lua client generates this dictionary from the complete LZW
11 | data the first time a string is accessed.
12 |
13 | The ID of a string is a 16-bit value equal to the address of the compressed
14 | data for the string in memory. The compressed string is stored as two bytes
15 | representing its compressed size (LSB first) followed by the codes. The
16 | ID is returned encoded as a string of 6-bit characters codes in the "pscii"
17 | character set.
18 |
19 | This is meant to be a drop-in replacement for TextLib, which was a silly idea
20 | that never compressed very well. Unlike TextLib, LzwLib does not distinguish
21 | between word and non-word characters, and preserves all characters including
22 | spaces, with the exception of newlines which are converted to single spaces.
23 | """
24 |
25 | from collections import OrderedDict
26 | import re
27 |
28 | __all__ = ['LzwLib', 'encode_pscii']
29 |
30 |
31 | # A character set, which I'm going to call "pscii", consisting of all of the
32 | # characters supported by TextLib. This corresponds to all of the characters
33 | # supported by Pico-8 v0.1.3. Notable missing chars include: $ \ @ ` (I
34 | # believe 0.1.4 will add support for "\".)
35 | CHAR_TABLE = ' !"#%\'()*+,-./0123456789:;<=>?abcdefghijklmnopqrstuvwxyz[]^_{~}'
36 |
37 | # For string-encoding IDs, add a character to make it an even 64 chars.
38 | CHAR_TABLE_FOR_SID = CHAR_TABLE + '@'
39 |
40 | # The Lua code for a string unpacker.
41 | #
42 | # _t(sid) unpacks and returns one or more strings. sid is a string containing
43 | # one or more string-encoded string IDs, three chars each. If sid contains
44 | # multiple three-char IDs, the return value is each of those strings
45 | # concatenated. This allows for the original Lua source to concatenate string
46 | # IDs as if they are the original string, then call _t() at the last moment
47 | # to unpack the aggregate result.
48 | #
49 | # _c(o) converts a character code to a single-character string.
50 | # _o(c) converts a single-character string to its character code (or nil).
51 | # _tlinit is true after the first call to _t().
52 | # _tladdr is the starting address of the compressed data.
53 | # _ct is the character table.
54 | #
55 | # (Note: Unlike TextLib's P8ADVENT_LUA_PAT, this is not a format pattern.)
56 | P8ADVENT_LUA = """
57 | _tl={a=nil,t=nil,d=nil,w=nil}
58 | function _tl:c(o) return sub(_tl.t,o+1,o+1) end
59 | function _tl:o(c)
60 | local i
61 | for i=1,#self.t do
62 | if sub(self.t,i,i)==c then return i-1 end
63 | end
64 | return 63
65 | end
66 | function _t(s)
67 | local p,r,c,n,i,a,l,bp,w,b
68 | local tid
69 | if _tl.d == nil then
70 | _tl.d={}
71 | n=bor(peek(_tl.a),shl(peek(_tl.a+1),8))
72 | a=_tl.a+2
73 | while n>0 do
74 | p=nil
75 | i=bor(peek(a),shl(peek(a+1),8))
76 | a+=5
77 | bp=0
78 | while i>0 do
79 | c=0
80 | w=_tl.w
81 | for bi=1,w do
82 | b=band(shr(peek(a),bp),1)
83 | c=bor(c,shl(b,bi-1))
84 | bp+=1
85 | if bp==8 then
86 | a+=1
87 | bp=0
88 | end
89 | end
90 | r=nil
91 | if c<=(#_tl.t-1) then
92 | r=_tl:c(c)
93 | elseif _tl.d[c-#_tl.t+1]~=nil then
94 | r=_tl.d[c-#_tl.t+1]
95 | end
96 | if p~=nil and #_tl.d+#_tl.t0 do
131 | c=0
132 | w=_tl.w
133 | for bi=1,w do
134 | b=band(shr(peek(a),bp),1)
135 | c=bor(c,shl(b,bi-1))
136 | bp+=1
137 | if bp==8 then
138 | a+=1
139 | bp=0
140 | end
141 | end
142 | l-=1
143 | if c<=(#_tl.t-1) then
144 | r=r.._tl:c(c)
145 | else
146 | r=r.._tl.d[c-#_tl.t+1]
147 | end
148 | tid+=1
149 | if tid==(2^_tl.w) then
150 | _tl.w+=1
151 | end
152 | end
153 | end
154 | return r
155 | end
156 | """
157 |
158 | LZW_STARTING_WIDTH = 7
159 |
160 | MAX_TABLE_ENTRY_COUNT = 4096
161 |
162 |
163 | def _generate_lua(start_addr):
164 | """Generate the Lua code for the string unpacker.
165 |
166 | Args:
167 | start_addr: The starting address of the data region.
168 |
169 | Returns:
170 | The Lua code, as a string.
171 | """
172 | # Remove leading spaces to reduce char footprint.
173 | lua = re.sub(r'\n +', '\n', P8ADVENT_LUA)
174 |
175 | return ('{}\n_tl.t="{}"\n_tl.a={}\n_tl.w={}\n_tl.mt={}\n'.format(
176 | lua,
177 | re.sub(r'"', '"..\'"\'.."', CHAR_TABLE),
178 | start_addr,
179 | LZW_STARTING_WIDTH,
180 | MAX_TABLE_ENTRY_COUNT))
181 |
182 |
183 | class Error(Exception):
184 | """A base class for errors."""
185 | pass
186 |
187 |
188 | class CharOutOfRange(Error):
189 | """A character was in a string that is not supported by pscii."""
190 | def __init__(self, *args, **kwargs):
191 | self.char = kwargs.get('char')
192 | self.pos = kwargs.get('pos')
193 | super().__init__(*args, **kwargs)
194 |
195 | def __str__(self):
196 | return ('Character out of range: {}, pos:{}'.format(
197 | repr(self.char),
198 | self.pos))
199 |
200 |
201 | class TooMuchDataError(Error):
202 | """The compressed data does not fit in the given cart data range.
203 | """
204 | def __init__(self, msg):
205 | self._msg = msg
206 |
207 | def __str__(self):
208 | return 'Too much data: {}'.format(self._msg)
209 |
210 |
211 | def encode_pscii(s):
212 | """Encode an ASCII string as a bytestring in terms of the character table.
213 |
214 | Args:
215 | s: The Python string to encode.
216 |
217 | Returns:
218 | The bytestring of indexes into CHAR_TABLE.
219 |
220 | Raises:
221 | ValueError: The string contains a character not in CHAR_TABLE.
222 | """
223 | result = bytearray()
224 | lower_s = s.lower()
225 | i = c = None
226 | try:
227 | for i, c in enumerate(lower_s):
228 | result.append(CHAR_TABLE.index(c))
229 | except ValueError as e:
230 | raise CharOutOfRange(c, i)
231 | return bytes(result)
232 |
233 |
234 | class LzwLib:
235 | def __init__(self, start_addr=0, end_addr=0x4300):
236 | """Initializer.
237 |
238 | You can use arguments to customize the addresses and maximum memory
239 | ranges for the compressed data in the cart and for the lookup
240 | dictionary in RAM.
241 |
242 | Args:
243 | start_addr: The Pico-8 cart data starting address for the data.
244 | end_addr: The Pico-8 cart data ending address for the data + 1.
245 | """
246 | self._start_addr = start_addr
247 | self._end_addr = end_addr
248 |
249 | self._string_id_map = dict()
250 | self._data = bytearray()
251 | self._dict = OrderedDict(
252 | (CHAR_TABLE[i], i) for i in range(len(CHAR_TABLE)))
253 |
254 | self._code_width = LZW_STARTING_WIDTH
255 | self._code_bit_pos = 0
256 | self._code_buffer = None
257 |
258 | def id_for_string(self, s):
259 | s = re.sub(r'\s+', ' ', s.lower())
260 | if s not in self._string_id_map:
261 | # TODO: dict length - 1?
262 | expected_table_id = len(self._dict)
263 | expected_code_width = self._code_width
264 |
265 | self._code_buffer = bytearray()
266 | self._code_bit_pos = 0
267 | sid = self._start_addr + 2 + len(self._data)
268 | start_i = 0
269 | code_count = 0
270 | while start_i < len(s):
271 | end_i = start_i + 1
272 | while end_i < len(s) and s[start_i:end_i] in self._dict:
273 | end_i += 1
274 | created_new_entry = None
275 | if s[start_i:end_i] not in self._dict:
276 | # (Condition may or may not be false at the end of the
277 | # string, so we check.)
278 | if len(self._dict) < MAX_TABLE_ENTRY_COUNT:
279 | self._dict[s[start_i:end_i]] = len(self._dict)
280 | created_new_entry = self._dict[s[start_i:end_i]]
281 | end_i -= 1
282 |
283 | code = self._dict[s[start_i:end_i]]
284 | for i in range(self._code_width):
285 | if self._code_bit_pos == 0:
286 | self._code_buffer.append(0)
287 | self._code_buffer[-1] |= (code & 1) << self._code_bit_pos
288 | code >>= 1
289 | self._code_bit_pos = (self._code_bit_pos + 1) % 8
290 |
291 | if (created_new_entry is not None and
292 | created_new_entry == (2**self._code_width - 1)):
293 | self._code_width += 1
294 |
295 | code_count += 1
296 | start_i = end_i
297 |
298 | self._data.append(code_count & 255)
299 | self._data.append(code_count >> 8)
300 | self._data.append(expected_table_id & 255)
301 | self._data.append(expected_table_id >> 8)
302 | self._data.append(expected_code_width)
303 | self._data.extend(self._code_buffer)
304 |
305 | encoded_sid = (CHAR_TABLE_FOR_SID[sid & 63] +
306 | CHAR_TABLE_FOR_SID[(sid >> 6) & 63] +
307 | CHAR_TABLE_FOR_SID[(sid >> 12) & 63])
308 | self._string_id_map[s] = encoded_sid
309 |
310 | return self._string_id_map[s]
311 |
312 | def as_bytes(self):
313 | """Get the binary data for the packed text.
314 |
315 | Returns:
316 | The data, as a bytearray.
317 |
318 | Raises:
319 | TooMuchDataError: The given strings do not fit into the memory
320 | ranges given to __init__.
321 | """
322 | string_count = len(self._string_id_map)
323 | data = (bytearray([string_count & 255, string_count >> 8]) +
324 | self._data)
325 |
326 | total_string_size = sum(len(k) for k in self._string_id_map.keys())
327 | compressed_data_size = len(data)
328 | lookup_table_count = len(self._dict)
329 | lookup_table_size = sum(len(k) for k in self._dict.keys())
330 | print(
331 | 'DEBUG: unique string count: {string_count}\n'
332 | 'DEBUG: total unique string size: {total_string_size}\n'
333 | 'DEBUG: lookup table entry count: {lookup_table_count}\n'
334 | 'DEBUG: compressed data size: {compressed_data_size}\n'
335 | 'DEBUG: lookup table size: {lookup_table_size}\n'
336 | .format(**locals()))
337 |
338 | if len(data) > (self._end_addr - self._start_addr):
339 | raise TooMuchDataError(
340 | 'compressed data is too large: {} bytes do not fit between '
341 | 'addresses {} and {}'.format(
342 | len(data), self._start_addr, self._end_addr))
343 | return data
344 |
345 | def generate_lua(self):
346 | """Generate the Lua code for accessing this LzwLib.
347 |
348 | Returns:
349 | The Lua code.
350 | """
351 | return _generate_lua(self._start_addr)
352 |
--------------------------------------------------------------------------------
/p8advent/textlib.py:
--------------------------------------------------------------------------------
1 | """A text library.
2 |
3 | TextLib stores a set of strings containing English text as compact binary
4 | data. It can also generate Pico-8 Lua code capable of accessing a string given
5 | the string's string ID (returned by the encoder method). The goal is to make it
6 | easy to write Pico-8 games that use a large quantity of English text without
7 | storing that text in the code region of the cart.
8 |
9 | Strings are not encoded exactly. To save space when storing multi-word
10 | English phrases, word spaces are not stored. The generated Lua code uses
11 | rules about English phrases to calculate word spacing in the final string.
12 |
13 | See p8advent.tool for code that generates a full Pico-8 cart that replaces
14 | string literals in Lua source with string library IDs. To allow code to defer
15 | string assembly until the last minute, the code must explicitly call the t(sid)
16 | function (added during cart processing) to get the string value. String IDs
17 | are encoded as strings, and can be concatenated. (sub() also works if you're
18 | careful: each string ID is three characters long.)
19 |
20 | TextLib uses a technique similar to the one used by old 8-bit text adventure
21 | games. Words are encoded as two bytes: a prefix ID and a suffix ID. A prefix
22 | is a fixed length that you set when you instantiate TextLib, typically 1 or 2.
23 | Each prefix has a list of suffixes indexed by the suffix ID. The decoded word
24 | is simply the prefix followed by the suffix. A string is a sequence of
25 | literal non-word characters and word byte pairs. See as_bytes() for a
26 | description of the complete binary representation.
27 |
28 | (It's debatable whether this is the best way to compress a set of short English
29 | phrases for a text game. It's also debatable whether a Pico-8 text game
30 | benefits from storing its text in a compacted form in cart data vs. in the
31 | code region. And of course making a text game in Pico-8 is a dubious endeavor
32 | to begin with. I just wanted to play with this technique.)
33 | """
34 |
35 | __all__ = ['TextLib', 'encode_pscii']
36 |
37 | from collections import defaultdict
38 | import re
39 | import sys
40 |
41 |
42 | _WORD = re.compile(r'[a-zA-Z\']+')
43 |
44 |
45 | # A character set, which I'm going to call "pscii", consisting of all of the
46 | # characters supported by TextLib. This corresponds to all of the characters
47 | # supported by Pico-8 v0.1.3. Notable missing chars include: $ \ @ ` (I
48 | # believe 0.1.4 will add support for "\".)
49 | CHAR_TABLE = ' !"#%\'()*+,-./0123456789:;<=>?abcdefghijklmnopqrstuvwxyz[]^_{~}'
50 |
51 |
52 | # A format pattern for the Lua code to inject. This expects a format key of
53 | # "text_start_addr" equal to the RAM address where the text data begins.
54 | #
55 | # _c(o) converts a character code to a single-character string.
56 | # _o(c) converts a single-character string to its character code (or nil).
57 | #
58 | # _t(sid) calculates the string with the given ID. It uses the string jump
59 | # table to find the character and word codes for the string, then builds the
60 | # result. If the next byte has its high bit set, then it and the following
61 | # byte are the prefix and suffix ID, respectively, of a word in the word
62 | # table. Otherwise it is a character code. For a word, it finds the prefix
63 | # using the word jump table, reads the prefix at that location (of a fixed
64 | # length encoded at pos 0), then scans a list of null-terminated suffixes to
65 | # find the appropriate suffix.
66 | #
67 | # Spaces are added according to English punctuation rules:
68 | #
69 | # * a space between words: "word word"
70 | # * a space after sentence ending punctuation and closing brackets if
71 | # followed by a word: !),.?:;]}
72 | # * a space after a word if followed by opening brackets: ([{
73 | # * double-quotes (") are treated as brackets, alternating between opening and
74 | # closing brackets
75 | #
76 | # Local variables in _t():
77 | #
78 | # * ta: The text data start absolute address.
79 | # * r : The result accumulator.
80 | # * sids : A list of string IDs encoded as a string of three-char segments.
81 | # * sid : The (numeric, decoded) string ID.
82 | # * sc : The sentence count.
83 | # * sa : The address of the first byte of the sentence string.
84 | # This pointer is advanced during the sentence string loop.
85 | # * sae : The address of the last byte of the sentence string + 1.
86 | # * psa : The value at the sentence string pointer.
87 | # * pi : The prefix index.
88 | # * si : The suffix index.
89 | # * wa : The address of the first byte of the prefix for the word.
90 | # * pl : The prefix length.
91 | # * pli : Prefix char index (0-based).
92 | # * was : The address of the start of the word table.
93 | # * lww : True if the last string part was a word.
94 | # * lwep : True if the last string part was sentence-ending or
95 | # bracket-closing punctuation.
96 | # * qt : True if the next double-quote is bracket-closing.
97 | #
98 | # TODO: Treat ~ (61) as a paragraph break, reset double-quote state.
99 | CHAR_TABLE_LUA = re.sub(r'"', '"..\'"\'.."', CHAR_TABLE)
100 | CHAR_TABLE_LUA = re.sub(r'{', '{{', CHAR_TABLE_LUA)
101 | CHAR_TABLE_LUA = re.sub(r'}', '}}', CHAR_TABLE_LUA)
102 | P8ADVENT_LUA_PAT = (
103 | '_ct="' + CHAR_TABLE_LUA + '"\n' +
104 | """
105 | function _c(o) return sub(_ct,o+1,o+1) end
106 | function _o(c)
107 | local i
108 | for i=1,#_ct do
109 | if sub(_ct,i,i)==c then return i-1 end
110 | end
111 | return 63
112 | end
113 | function _t(sids)
114 | local ta={text_start_addr}
115 | local sidsi,sid,r,sc,sa,sae,psa,pi,si,wa,pl,pli,was,lww,lwep,qt
116 | pl=peek(ta)
117 | sc=bor(shl(peek(ta+2),8),peek(ta+1))
118 | was=ta+bor(shl(peek(ta+sc*2+4),8),peek(ta+sc*2+3))
119 | r=''
120 | lww=false
121 | lwep=false
122 | qt=false
123 | for sidsi=1,#sids,3 do
124 | sid=bor(bor(_o(sub(sids,sidsi,sidsi)),
125 | shl(_o(sub(sids,sidsi+1,sidsi+1)),6)),
126 | shl(_o(sub(sids,sidsi+2,sidsi+2)),12))
127 | sa=ta+bor(shl(peek(ta+sid*2+4),8),peek(ta+sid*2+3))
128 | sae=ta+bor(shl(peek(ta+(sid+1)*2+4),8),peek(ta+(sid+1)*2+3))
129 | while sa 0) r=r.._c(peek(wa+pli))
138 | end
139 | wa=wa+pl
140 | while si>0 do
141 | while band(peek(wa),128)~=128 and peek(wa)~=0 do wa+=1 end
142 | wa+=1
143 | si-=1
144 | end
145 | repeat
146 | if peek(wa)==0 then break end
147 | r=r.._c(band(peek(wa),127))
148 | wa+=1
149 | until band(peek(wa-1),128)==128
150 | sa+=1
151 | lww=true
152 | lwep=false
153 | else
154 | if ((lww and ((psa==2 and qt)or(psa==6)or(psa==56)or(psa==60))) or
155 | (lwep and psa==2 and not qt)) then
156 | r=r.." "
157 | end
158 | r=r.._c(psa)
159 | lww=false
160 | lwep=((psa==2 and qt)or(psa==7)or(psa==10)or(psa==12)or(psa==24)or
161 | (psa==25)or(psa==29)or(psa==57)or(psa==62))
162 | if (psa==2) qt=not qt
163 | end
164 | sa+=1
165 | end
166 | end
167 | return r
168 | end
169 | """)
170 |
171 |
172 | class Error(Exception):
173 | """A base class for errors."""
174 | pass
175 |
176 |
177 | class TooManyWordsForPrefixError(Error):
178 | """There were too many words with the same prefix.
179 |
180 | If this happens, increase the prefix length and try again.
181 | """
182 | pass
183 |
184 |
185 | def encode_pscii(s):
186 | """Encode an ASCII string as a bytestring in terms of the character table.
187 |
188 | Args:
189 | s: The Python string to encode.
190 |
191 | Returns:
192 | The bytestring of indexes into CHAR_TABLE.
193 |
194 | Raises:
195 | ValueError: The string contains a character not in CHAR_TABLE.
196 | """
197 | result = bytearray()
198 | lower_s = s.lower()
199 | ce = None
200 | try:
201 | for c in lower_s:
202 | ce = c
203 | result.append(CHAR_TABLE.index(c))
204 | except ValueError as e:
205 | sys.stderr.write('Character out of supported range: {}\n'.format(
206 | repr(ce)))
207 | raise
208 | return bytes(result)
209 |
210 |
211 | class TextLib:
212 | def __init__(self, prefix_length=1):
213 | self._prefix_lst = list()
214 | self._word_lib = defaultdict(list)
215 | self._string_lib_map = dict()
216 | self._string_lib_lst = list()
217 | self._prefix_length = prefix_length
218 |
219 | self._total_chars_stored = 0
220 |
221 | def _encode_word(self, w):
222 | """Encodes a word, adding it to the library if necessary.
223 |
224 | The result is a prefix index followed by a lookup index for the suffix.
225 |
226 | If a prefix index grows beyond 127 or a suffix index grows beyond 255,
227 | we raise an exception. If this happens, increase the prefix length
228 | and try again. (A test with a very large document needed a 10-bit
229 | index with a 1-byte prefix, but an 8-bit index with a 2-byte prefix.)
230 |
231 | Args:
232 | w: The word.
233 |
234 | Returns:
235 | A bytestring, either or a pscii bytestring if
236 | the word is shorter than the prefix length.
237 | """
238 | w = encode_pscii(w)
239 | if len(w) <= self._prefix_length:
240 | w += b'\x00' * (self._prefix_length - len(w))
241 | prefix = w
242 | suffix = b''
243 | else:
244 | prefix = w[:self._prefix_length]
245 | suffix = w[self._prefix_length:]
246 | if prefix not in self._word_lib:
247 | self._prefix_lst.append(prefix)
248 | prefix_id = len(self._prefix_lst) - 1
249 | if prefix_id > 127:
250 | raise TooManyWordsForPrefixError()
251 | else:
252 | prefix_id = self._prefix_lst.index(prefix)
253 |
254 | if suffix in self._word_lib[prefix]:
255 | suffix_id = self._word_lib[prefix].index(suffix)
256 | else:
257 | self._word_lib[prefix].append(suffix)
258 | suffix_id = len(self._word_lib[prefix]) - 1
259 | if suffix_id > 255:
260 | raise TooManyWordsForPrefixError()
261 |
262 | # Set high bit of prefix ID.
263 | prefix_id |= 128
264 |
265 | return bytes((prefix_id, suffix_id))
266 |
267 | def _encode_string(self, s):
268 | """Encodes the symbols of a string.
269 |
270 | Args:
271 | s: The string.
272 |
273 | Returns:
274 | The byte encoding for the string.
275 | """
276 | result = bytearray()
277 | s_i = 0
278 | while s_i < len(s):
279 | if s[s_i] == ' ':
280 | s_i += 1
281 | continue
282 | m = _WORD.match(s[s_i:])
283 | if not m:
284 | result.extend(encode_pscii(s[s_i]))
285 | s_i += 1
286 | continue
287 | result.extend(self._encode_word(m.group(0)))
288 | s_i += len(m.group(0))
289 | return result
290 |
291 | def _encode_string_id(self, id):
292 | """Encodes a string ID as three pscii characters.
293 |
294 | Args:
295 | id: The numeric ID, from 0 to 65535.
296 |
297 | Returns:
298 | The three-character string encoding of the ID.
299 | """
300 | # Add a special char to the table to make it 64 chars even.
301 | ct = CHAR_TABLE + '@'
302 | w1 = id & 63
303 | w2 = (id >> 6) & 63
304 | w3 = (id >> 12) & 63
305 | return ct[w1] + ct[w2] + ct[w3]
306 |
307 | def id_for_string(self, s):
308 | """Gets the ID for a string, adding it to the library if necessary.
309 |
310 | Args:
311 | s: The string.
312 |
313 | Returns:
314 | The string ID, encoded as a three-character pscii string.
315 | """
316 | s = re.sub(r'\s+', ' ', s)
317 | self._total_chars_stored += len(s) # for stats
318 | if s not in self._string_lib_map:
319 | self._string_lib_lst.append(self._encode_string(s))
320 | self._string_lib_map[s] = len(self._string_lib_lst) - 1
321 | return self._encode_string_id(self._string_lib_map[s])
322 |
323 | def as_bytes(self):
324 | """Dump the entire library in its byte encoding.
325 |
326 | The prefix length and table sizes are not encoded. It is expected
327 | that the generated access code will stay within expected ranges.
328 | TODO: This is dumb. I'm passing these values into the generated Lua,
329 | might as well store them with the bytes.
330 |
331 | 0: The prefix length.
332 | 1 - 2: The number of sentences, S, LSB first.
333 | 3 - 2*S+2: The string jump table, each entry as an offset from pos 0,
334 | two bytes each, LSB first.
335 | 2*S+3 - 2*S+4: The offset of the byte following the last byte of string
336 | S, LSB first. This serves two purposes: it allows the string reader
337 | to read two offsets from the jump table to get the length, and it's
338 | the offset of the word lookup table (W_addr).
339 | 2*S+5 - ...: Encoded strings. If a byte has its high bit set, then it is
340 | a word prefix offset and the next byte is the suffix offset.
341 | Otherwise a given byte is a pscii code. Word spaces are omitted, and
342 | are up to the renderer to provide according to English punctuation
343 | rules.
344 | W_addr - W_addr+2*W-1: The prefix jump table, each entry as an offset
345 | from pos 0, two bytes each, LSB first.
346 | W_addr+2*W - ...: Word entries, null terminated. Each entry starts with
347 | the prefix (in pscii) followed by all of the suffixes (in pscii).
348 | Each suffix's final character has its high bit set.
349 |
350 | Returns:
351 | A bytearray.
352 | """
353 | longest_string_size = 0
354 | most_lookup_entries_count = 0
355 | total_lookup_entries_count = 0
356 | longest_suffix_size = 0
357 |
358 | string_offset_list = [0]
359 | string_data = bytearray()
360 | for s in self._string_lib_lst:
361 | string_data.extend(s)
362 | string_offset_list.append(len(string_data))
363 | if len(string_data) > longest_string_size:
364 | longest_string_size = len(string_data)
365 | string_table_offset = 3 + 2 * len(self._string_lib_lst) + 2
366 | string_jump_tbl = bytearray()
367 | for e in string_offset_list:
368 | v = string_table_offset + e
369 | if v >= 65536:
370 | raise TooManyWordsForPrefixError()
371 | string_jump_tbl.append(v & 255)
372 | string_jump_tbl.append(v >> 8)
373 |
374 | lookup_offset_list = [0]
375 | lookup_data = bytearray()
376 | for p in self._prefix_lst:
377 | lookup_data.extend(p)
378 | for suffix in self._word_lib[p]:
379 | if len(suffix) > 0:
380 | lookup_data.extend(suffix)
381 | lookup_data[-1] |= 0x80
382 | else:
383 | lookup_data.append(0)
384 | if len(suffix) > longest_suffix_size:
385 | longest_suffix_size = len(suffix)
386 | lookup_offset_list.append(len(lookup_data))
387 | if len(self._word_lib[p]) > most_lookup_entries_count:
388 | most_lookup_entries_count = len(self._word_lib[p])
389 | total_lookup_entries_count += len(self._word_lib[p])
390 | lookup_table_offset = (3 + len(string_jump_tbl) + len(string_data) +
391 | 2 * len(self._prefix_lst))
392 | lookup_prefix_tbl = bytearray()
393 | # We don't need the offset past the last lookup:
394 | lookup_offset_list.pop()
395 | for e in lookup_offset_list:
396 | v = lookup_table_offset + e
397 | if v >= 65536:
398 | raise TooManyWordsForPrefixError()
399 | lookup_prefix_tbl.append(v & 255)
400 | lookup_prefix_tbl.append(v >> 8)
401 |
402 | num_of_strings = len(self._string_lib_lst)
403 | num_of_prefixes = len(self._prefix_lst)
404 |
405 | # TODO: remove these, or make them an official feature:
406 | print('DEBUG: num_of_strings = {}'.format(num_of_strings))
407 | print('DEBUG: num_of_prefixes = {}'.format(num_of_prefixes))
408 | print('DEBUG: longest_string_size = {}'.format(longest_string_size))
409 | print('DEBUG: longest_suffix_size = {}'.format(longest_suffix_size))
410 | print('DEBUG: most_lookup_entries_count = {}'.format(most_lookup_entries_count))
411 | print('DEBUG: total_lookup_entries_count = {}'.format(total_lookup_entries_count))
412 | print('DEBUG: original text size = {}'.format(self._total_chars_stored))
413 | print('DEBUG: total text lib size = {}'.format(len(string_jump_tbl) +
414 | len(string_data) +
415 | len(lookup_prefix_tbl) +
416 | len(lookup_data)))
417 |
418 | return bytes(bytearray([self._prefix_length,
419 | len(self._string_lib_lst) & 255,
420 | len(self._string_lib_lst) >> 8]) +
421 | string_jump_tbl +
422 | string_data +
423 | lookup_prefix_tbl +
424 | lookup_data)
425 |
426 | def generate_lua(self, text_start_addr=0):
427 | """Generate the Lua code for accessing this TextLib.
428 |
429 | Args:
430 | text_start_addr: The starting address for the text bytes region.
431 | """
432 | return P8ADVENT_LUA_PAT.format(text_start_addr=text_start_addr)
433 |
--------------------------------------------------------------------------------
/p8advent/tool.py:
--------------------------------------------------------------------------------
1 | """The main routines for the command-line tool.
2 |
3 | This tool processes a Lua source file into a Pico-8 cart. It adds a simple
4 | syntax rule for string literals: If a string literal is immediately preceded
5 | by a star (*), the string is added to a text lib data structure, and the
6 | string literal is replaced with an encoded string ID (also a string). To get
7 | the original string, the Lua code must call the _t() function (added to the
8 | Lua code by the tool) and pass it the string ID. For example:
9 |
10 | function _update()
11 | msg = *"check it out everyone it's the apocalypse!"
12 | print(_t(msg))
13 | end
14 |
15 | This becomes:
16 |
17 | function _update()
18 | msg = " "
19 | print(_t(msg))
20 | end
21 | function _t(sid)
22 | ...
23 | end
24 |
25 | The string data is moved into the graphics region of the Pico-8 cart. The _t()
26 | function uses the encoded ID to locate and unpack the string.
27 | """
28 |
29 | __all__ = ['main']
30 |
31 | import argparse
32 | import textwrap
33 |
34 | from pico8.game import game
35 | from pico8.lua import lexer
36 |
37 | from . import lzwlib
38 |
39 |
40 | # To preserve a portion of the gfx region, use 512 * number of sprite rows.
41 | TEXT_START_ADDR = 0
42 |
43 | # To preserve the song/sfx region, use 0x3100.
44 | TEXT_END_ADDR = 0x4300
45 |
46 |
47 | def _get_argparser():
48 | """Builds and returns the argument parser."""
49 | parser = argparse.ArgumentParser(
50 | formatter_class=argparse.RawDescriptionHelpFormatter,
51 | usage='%(prog)s [--help] --lua ',
52 | description=textwrap.dedent('''
53 | '''))
54 | parser.add_argument(
55 | '--lua', type=str, required=True,
56 | help='the annotated Lua code for the game')
57 | parser.add_argument(
58 | '--startaddr', type=int, default=TEXT_START_ADDR,
59 | help='the Pico-8 cart address to put the text data')
60 | parser.add_argument(
61 | '--endaddr', type=int, default=TEXT_END_ADDR,
62 | help='the Pico-8 cart address the the text data must end; if the text'
63 | 'data is too long, this tool reports an error')
64 | return parser
65 |
66 |
67 | def main(orig_args):
68 | arg_parser = _get_argparser()
69 | args = arg_parser.parse_args(args=orig_args)
70 |
71 | assert args.lua.endswith('.lua')
72 | game_fname = args.lua[:-len('.lua')] + '.p8'
73 |
74 | my_game = game.Game.make_empty_game(filename=game_fname)
75 | my_lexer = lexer.Lexer(version=4)
76 | with open(args.lua) as lua_fh:
77 | my_lexer.process_lines(lua_fh)
78 |
79 | my_textlib = lzwlib.LzwLib(start_addr=args.startaddr, end_addr=args.endaddr)
80 |
81 | saw_star = False
82 | for i, token in enumerate(my_lexer._tokens):
83 | if token.matches(lexer.TokSymbol('*')):
84 | saw_star = True
85 | elif token.matches(lexer.TokString) and saw_star:
86 | sid = my_textlib.id_for_string(token.value)
87 | my_lexer._tokens[i-1] = lexer.TokSpace('')
88 | my_lexer._tokens[i] = lexer.TokString(sid)
89 | saw_star = False
90 | else:
91 | saw_star = False
92 |
93 | textlib_lua = my_textlib.generate_lua()
94 | my_lexer.process_lines(l+'\n' for l in textlib_lua.split('\n'))
95 |
96 | my_game.lua._lexer = my_lexer
97 | my_game.lua._parser.process_tokens(my_game.lua._lexer.tokens)
98 |
99 | text_bytes = my_textlib.as_bytes()
100 | my_game.write_cart_data(text_bytes, args.startaddr)
101 |
102 | with open(game_fname, 'w') as outstr:
103 | my_game.to_p8_file(outstr, filename=game_fname)
104 |
105 | return 0
106 |
--------------------------------------------------------------------------------
/tests/testdata/pager.lua:
--------------------------------------------------------------------------------
1 | printer = nil
2 | msgs = {
3 |
4 | *"Book the First--Recalled to Life",
5 |
6 | *"I. The Period",
7 |
8 | *"It was the best of times, it was the worst of times,
9 | it was the age of wisdom, it was the age of foolishness,
10 | it was the epoch of belief, it was the epoch of incredulity,
11 | it was the season of Light, it was the season of Darkness,
12 | it was the spring of hope, it was the winter of despair,
13 | we had everything before us, we had nothing before us,
14 | we were all going direct to Heaven,
15 | we were all going direct the other way--
16 | in short, the period was so far like the present period, that some of
17 | its noisiest authorities insisted on its being received, for good or for
18 | evil, in the superlative degree of comparison only.",
19 |
20 | *"There were a king with a large jaw and a queen with a plain face, on the
21 | throne of England; there were a king with a large jaw and a queen with
22 | a fair face, on the throne of France. In both countries it was clearer
23 | than crystal to the lords of the State preserves of loaves and fishes,
24 | that things in general were settled for ever.",
25 |
26 | *"It was the year of Our Lord one thousand seven hundred and seventy-five.
27 | Spiritual revelations were conceded to England at that favoured period,
28 | as at this. Mrs. Southcott had recently attained her five-and-twentieth
29 | blessed birthday, of whom a prophetic private in the Life Guards had
30 | heralded the sublime appearance by announcing that arrangements were
31 | made for the swallowing up of London and Westminster. Even the Cock-lane
32 | ghost had been laid only a round dozen of years, after rapping out its
33 | messages, as the spirits of this very year last past (supernaturally
34 | deficient in originality) rapped out theirs. Mere messages in the
35 | earthly order of events had lately come to the English Crown and People,
36 | from a congress of British subjects in America: which, strange
37 | to relate, have proved more important to the human race than any
38 | communications yet received through any of the chickens of the Cock-lane
39 | brood.",
40 |
41 | *"France, less favoured on the whole as to matters spiritual than her
42 | sister of the shield and trident, rolled with exceeding smoothness down
43 | hill, making paper money and spending it. Under the guidance of her
44 | Christian pastors, she entertained herself, besides, with such humane
45 | achievements as sentencing a youth to have his hands cut off, his tongue
46 | torn out with pincers, and his body burned alive, because he had not
47 | kneeled down in the rain to do honour to a dirty procession of monks
48 | which passed within his view, at a distance of some fifty or sixty
49 | yards. It is likely enough that, rooted in the woods of France and
50 | Norway, there were growing trees, when that sufferer was put to death,
51 | already marked by the Woodman, Fate, to come down and be sawn into
52 | boards, to make a certain movable framework with a sack and a knife in
53 | it, terrible in history. It is likely enough that in the rough outhouses
54 | of some tillers of the heavy lands adjacent to Paris, there were
55 | sheltered from the weather that very day, rude carts, bespattered with
56 | rustic mire, snuffed about by pigs, and roosted in by poultry, which
57 | the Farmer, Death, had already set apart to be his tumbrils of
58 | the Revolution. But that Woodman and that Farmer, though they work
59 | unceasingly, work silently, and no one heard them as they went about
60 | with muffled tread: the rather, forasmuch as to entertain any suspicion
61 | that they were awake, was to be atheistical and traitorous.",
62 |
63 | *"In England, there was scarcely an amount of order and protection to
64 | justify much national boasting. Daring burglaries by armed men, and
65 | highway robberies, took place in the capital itself every night;
66 | families were publicly cautioned not to go out of town without removing
67 | their furniture to upholsterers' warehouses for security; the highwayman
68 | in the dark was a City tradesman in the light, and, being recognised and
69 | challenged by his fellow-tradesman whom he stopped in his character of
70 | \"the Captain,\" gallantly shot him through the head and rode away; the
71 | mail was waylaid by seven robbers, and the guard shot three dead, and
72 | then got shot dead himself by the other four, \"in consequence of the
73 | failure of his ammunition:\" after which the mail was robbed in peace;
74 | that magnificent potentate, the Lord Mayor of London, was made to stand
75 | and deliver on Turnham Green, by one highwayman, who despoiled the
76 | illustrious creature in sight of all his retinue; prisoners in London
77 | gaols fought battles with their turnkeys, and the majesty of the law
78 | fired blunderbusses in among them, loaded with rounds of shot and ball;
79 | thieves snipped off diamond crosses from the necks of noble lords at
80 | Court drawing-rooms; musketeers went into St. Giles's, to search
81 | for contraband goods, and the mob fired on the musketeers, and the
82 | musketeers fired on the mob, and nobody thought any of these occurrences
83 | much out of the common way. In the midst of them, the hangman, ever busy
84 | and ever worse than useless, was in constant requisition; now, stringing
85 | up long rows of miscellaneous criminals; now, hanging a housebreaker on
86 | Saturday who had been taken on Tuesday; now, burning people in the
87 | hand at Newgate by the dozen, and now burning pamphlets at the door of
88 | Westminster Hall; to-day, taking the life of an atrocious murderer,
89 | and to-morrow of a wretched pilferer who had robbed a farmer's boy of
90 | sixpence.",
91 |
92 | *"All these things, and a thousand like them, came to pass in and close
93 | upon the dear old year one thousand seven hundred and seventy-five.
94 | Environed by them, while the Woodman and the Farmer worked unheeded,
95 | those two of the large jaws, and those other two of the plain and the
96 | fair faces, trod with stir enough, and carried their divine rights
97 | with a high hand. Thus did the year one thousand seven hundred
98 | and seventy-five conduct their Greatnesses, and myriads of small
99 | creatures--the creatures of this chronicle among the rest--along the
100 | roads that lay before them.",
101 |
102 | *"II. The Mail",
103 |
104 | *"It was the Dover road that lay, on a Friday night late in November,
105 | before the first of the persons with whom this history has business.
106 | The Dover road lay, as to him, beyond the Dover mail, as it lumbered up
107 | Shooter's Hill. He walked up hill in the mire by the side of the mail,
108 | as the rest of the passengers did; not because they had the least relish
109 | for walking exercise, under the circumstances, but because the hill,
110 | and the harness, and the mud, and the mail, were all so heavy, that the
111 | horses had three times already come to a stop, besides once drawing the
112 | coach across the road, with the mutinous intent of taking it back
113 | to Blackheath. Reins and whip and coachman and guard, however, in
114 | combination, had read that article of war which forbade a purpose
115 | otherwise strongly in favour of the argument, that some brute animals
116 | are endued with Reason; and the team had capitulated and returned to
117 | their duty.",
118 |
119 | *"With drooping heads and tremulous tails, they mashed their way through
120 | the thick mud, floundering and stumbling between whiles, as if they were
121 | falling to pieces at the larger joints. As often as the driver rested
122 | them and brought them to a stand, with a wary \"Wo-ho! so-ho-then!\" the
123 | near leader violently shook his head and everything upon it--like an
124 | unusually emphatic horse, denying that the coach could be got up the
125 | hill. Whenever the leader made this rattle, the passenger started, as a
126 | nervous passenger might, and was disturbed in mind.",
127 |
128 | *"There was a steaming mist in all the hollows, and it had roamed in its
129 | forlornness up the hill, like an evil spirit, seeking rest and finding
130 | none. A clammy and intensely cold mist, it made its slow way through the
131 | air in ripples that visibly followed and overspread one another, as the
132 | waves of an unwholesome sea might do. It was dense enough to shut out
133 | everything from the light of the coach-lamps but these its own workings,
134 | and a few yards of road; and the reek of the labouring horses steamed
135 | into it, as if they had made it all.",
136 |
137 | *"Two other passengers, besides the one, were plodding up the hill by the
138 | side of the mail. All three were wrapped to the cheekbones and over the
139 | ears, and wore jack-boots. Not one of the three could have said, from
140 | anything he saw, what either of the other two was like; and each was
141 | hidden under almost as many wrappers from the eyes of the mind, as from
142 | the eyes of the body, of his two companions. In those days, travellers
143 | were very shy of being confidential on a short notice, for anybody on
144 | the road might be a robber or in league with robbers. As to the latter,
145 | when every posting-house and ale-house could produce somebody in
146 | \"the Captain's\" pay, ranging from the landlord to the lowest stable
147 | non-descript, it was the likeliest thing upon the cards. So the guard
148 | of the Dover mail thought to himself, that Friday night in November, one
149 | thousand seven hundred and seventy-five, lumbering up Shooter's Hill, as
150 | he stood on his own particular perch behind the mail, beating his feet,
151 | and keeping an eye and a hand on the arm-chest before him, where a
152 | loaded blunderbuss lay at the top of six or eight loaded horse-pistols,
153 | deposited on a substratum of cutlass.",
154 |
155 |
156 |
157 | *"The Dover mail was in its usual genial position that the guard suspected
158 | the passengers, the passengers suspected one another and the guard, they
159 | all suspected everybody else, and the coachman was sure of nothing but
160 | the horses; as to which cattle he could with a clear conscience have
161 | taken his oath on the two Testaments that they were not fit for the
162 | journey.",
163 |
164 | *"\"Wo-ho!\" said the coachman. \"So, then! One more pull and you're at the
165 | top and be damned to you, for I have had trouble enough to get you to
166 | it!--Joe!\"",
167 |
168 | *"\"Halloa!\" the guard replied.",
169 |
170 | *"\"What o'clock do you make it, Joe?\"",
171 |
172 | *"\"Ten minutes, good, past eleven.\"",
173 |
174 | *"\"My blood!\" ejaculated the vexed coachman, \"and not atop of Shooter's
175 | yet! Tst! Yah! Get on with you!\"",
176 |
177 | *"The emphatic horse, cut short by the whip in a most decided negative,
178 | made a decided scramble for it, and the three other horses followed
179 | suit. Once more, the Dover mail struggled on, with the jack-boots of its
180 | passengers squashing along by its side. They had stopped when the coach
181 | stopped, and they kept close company with it. If any one of the three
182 | had had the hardihood to propose to another to walk on a little ahead
183 | into the mist and darkness, he would have put himself in a fair way of
184 | getting shot instantly as a highwayman.",
185 |
186 | *"The last burst carried the mail to the summit of the hill. The horses
187 | stopped to breathe again, and the guard got down to skid the wheel for
188 | the descent, and open the coach-door to let the passengers in.",
189 |
190 | *"\"Tst! Joe!\" cried the coachman in a warning voice, looking down from his
191 | box.",
192 |
193 | *"\"What do you say, Tom?\"",
194 |
195 | *"They both listened.",
196 |
197 | *"\"I say a horse at a canter coming up, Joe.\"",
198 |
199 | *"\"_I_ say a horse at a gallop, Tom,\" returned the guard, leaving his hold
200 | of the door, and mounting nimbly to his place. \"Gentlemen! In the king's
201 | name, all of you!\"",
202 |
203 | *"With this hurried adjuration, he cocked his blunderbuss, and stood on
204 | the offensive.",
205 |
206 | *"The passenger booked by this history, was on the coach-step, getting in;
207 | the two other passengers were close behind him, and about to follow. He
208 | remained on the step, half in the coach and half out of; they remained
209 | in the road below him. They all looked from the coachman to the guard,
210 | and from the guard to the coachman, and listened. The coachman looked
211 | back and the guard looked back, and even the emphatic leader pricked up
212 | his ears and looked back, without contradicting.",
213 |
214 | *"The stillness consequent on the cessation of the rumbling and labouring
215 | of the coach, added to the stillness of the night, made it very quiet
216 | indeed. The panting of the horses communicated a tremulous motion to
217 | the coach, as if it were in a state of agitation. The hearts of the
218 | passengers beat loud enough perhaps to be heard; but at any rate, the
219 | quiet pause was audibly expressive of people out of breath, and holding
220 | the breath, and having the pulses quickened by expectation.",
221 |
222 | *"The sound of a horse at a gallop came fast and furiously up the hill.",
223 |
224 | *"\"So-ho!\" the guard sang out, as loud as he could roar. \"Yo there! Stand!
225 | I shall fire!\"",
226 |
227 | *"The pace was suddenly checked, and, with much splashing and floundering,
228 | a man's voice called from the mist, \"Is that the Dover mail?\"",
229 |
230 | *"\"Never you mind what it is!\" the guard retorted. \"What are you?\"",
231 |
232 | *"\"_Is_ that the Dover mail?\"",
233 |
234 | *"\"Why do you want to know?\"",
235 |
236 | *"\"I want a passenger, if it is.\"",
237 |
238 | *"\"What passenger?\"",
239 |
240 | *"\"Mr. Jarvis Lorry.\"",
241 |
242 | *"Our booked passenger showed in a moment that it was his name. The guard,
243 | the coachman, and the two other passengers eyed him distrustfully.",
244 |
245 | *"\"Keep where you are,\" the guard called to the voice in the mist,
246 | \"because, if I should make a mistake, it could never be set right in
247 | your lifetime. Gentleman of the name of Lorry answer straight.\"",
248 |
249 | *"\"What is the matter?\" asked the passenger, then, with mildly quavering
250 | speech. \"Who wants me? Is it Jerry?\"",
251 |
252 | *"(\"I don't like Jerry's voice, if it is Jerry,\" growled the guard to
253 | himself. \"He's hoarser than suits me, is Jerry.\")",
254 |
255 | *"\"Yes, Mr. Lorry.\"",
256 |
257 | *"\"What is the matter?\"",
258 |
259 | *"\"A despatch sent after you from over yonder. T. and Co.\"",
260 |
261 | *"\"I know this messenger, guard,\" said Mr. Lorry, getting down into the
262 | road--assisted from behind more swiftly than politely by the other two
263 | passengers, who immediately scrambled into the coach, shut the door, and
264 | pulled up the window. \"He may come close; there's nothing wrong.\"",
265 |
266 | *"\"I hope there ain't, but I can't make so 'Nation sure of that,\" said the
267 | guard, in gruff soliloquy. \"Hallo you!\"",
268 |
269 | *"\"Well! And hallo you!\" said Jerry, more hoarsely than before.",
270 |
271 | *"\"Come on at a footpace! d'ye mind me? And if you've got holsters to that
272 | saddle o' yourn, don't let me see your hand go nigh 'em. For I'm a devil
273 | at a quick mistake, and when I make one it takes the form of Lead. So
274 | now let's look at you.\"",
275 |
276 | *"The figures of a horse and rider came slowly through the eddying mist,
277 | and came to the side of the mail, where the passenger stood. The rider
278 | stooped, and, casting up his eyes at the guard, handed the passenger
279 | a small folded paper. The rider's horse was blown, and both horse and
280 | rider were covered with mud, from the hoofs of the horse to the hat of
281 | the man.",
282 |
283 | *"\"Guard!\" said the passenger, in a tone of quiet business confidence.",
284 |
285 | *"The watchful guard, with his right hand at the stock of his raised
286 | blunderbuss, his left at the barrel, and his eye on the horseman,
287 | answered curtly, \"Sir.\"",
288 |
289 | *"\"There is nothing to apprehend. I belong to Tellson's Bank. You must
290 | know Tellson's Bank in London. I am going to Paris on business. A crown
291 | to drink. I may read this?\"",
292 |
293 | *"\"If so be as you're quick, sir.\"",
294 |
295 | *"He opened it in the light of the coach-lamp on that side, and
296 | read--first to himself and then aloud: \"'Wait at Dover for Mam'selle.'
297 | It's not long, you see, guard. Jerry, say that my answer was, RECALLED
298 | TO LIFE.\"",
299 |
300 | *"Jerry started in his saddle. \"That's a Blazing strange answer, too,\"
301 | said he, at his hoarsest.",
302 |
303 | *"\"Take that message back, and they will know that I received this, as
304 | well as if I wrote. Make the best of your way. Good night.\"",
305 |
306 | *"With those words the passenger opened the coach-door and got in; not at
307 | all assisted by his fellow-passengers, who had expeditiously secreted
308 | their watches and purses in their boots, and were now making a general
309 | pretence of being asleep. With no more definite purpose than to escape
310 | the hazard of originating any other kind of action.",
311 |
312 | *"The coach lumbered on again, with heavier wreaths of mist closing round
313 | it as it began the descent. The guard soon replaced his blunderbuss
314 | in his arm-chest, and, having looked to the rest of its contents, and
315 | having looked to the supplementary pistols that he wore in his belt,
316 | looked to a smaller chest beneath his seat, in which there were a
317 | few smith's tools, a couple of torches, and a tinder-box. For he was
318 | furnished with that completeness that if the coach-lamps had been blown
319 | and stormed out, which did occasionally happen, he had only to shut
320 | himself up inside, keep the flint and steel sparks well off the straw,
321 | and get a light with tolerable safety and ease (if he were lucky) in
322 | five minutes.",
323 |
324 | *"\"Tom!\" softly over the coach roof.",
325 |
326 | *"\"Hallo, Joe.\"",
327 |
328 | *"\"Did you hear the message?\"",
329 |
330 | *"\"I did, Joe.\"",
331 |
332 | *"\"What did you make of it, Tom?\"",
333 |
334 | *"\"Nothing at all, Joe.\"",
335 |
336 | *"\"That's a coincidence, too,\" the guard mused, \"for I made the same of it
337 | myself.\"",
338 |
339 | *"Jerry, left alone in the mist and darkness, dismounted meanwhile, not
340 | only to ease his spent horse, but to wipe the mud from his face, and
341 | shake the wet out of his hat-brim, which might be capable of
342 | holding about half a gallon. After standing with the bridle over his
343 | heavily-splashed arm, until the wheels of the mail were no longer within
344 | hearing and the night was quite still again, he turned to walk down the
345 | hill.",
346 |
347 | *"\"After that there gallop from Temple Bar, old lady, I won't trust your
348 | fore-legs till I get you on the level,\" said this hoarse messenger,
349 | glancing at his mare. \"'Recalled to life.' That's a Blazing strange
350 | message. Much of that wouldn't do for you, Jerry! I say, Jerry! You'd
351 | be in a Blazing bad way, if recalling to life was to come into fashion,
352 | Jerry!\"",
353 |
354 | *"III. The Night Shadows",
355 |
356 | *"A wonderful fact to reflect upon, that every human creature is
357 | constituted to be that profound secret and mystery to every other. A
358 | solemn consideration, when I enter a great city by night, that every
359 | one of those darkly clustered houses encloses its own secret; that every
360 | room in every one of them encloses its own secret; that every beating
361 | heart in the hundreds of thousands of breasts there, is, in some of
362 | its imaginings, a secret to the heart nearest it! Something of the
363 | awfulness, even of Death itself, is referable to this. No more can I
364 | turn the leaves of this dear book that I loved, and vainly hope in time
365 | to read it all. No more can I look into the depths of this unfathomable
366 | water, wherein, as momentary lights glanced into it, I have had glimpses
367 | of buried treasure and other things submerged. It was appointed that the
368 | book should shut with a spring, for ever and for ever, when I had read
369 | but a page. It was appointed that the water should be locked in an
370 | eternal frost, when the light was playing on its surface, and I stood
371 | in ignorance on the shore. My friend is dead, my neighbour is dead,
372 | my love, the darling of my soul, is dead; it is the inexorable
373 | consolidation and perpetuation of the secret that was always in that
374 | individuality, and which I shall carry in mine to my life's end. In
375 | any of the burial-places of this city through which I pass, is there
376 | a sleeper more inscrutable than its busy inhabitants are, in their
377 | innermost personality, to me, or than I am to them?",
378 |
379 | *"As to this, his natural and not to be alienated inheritance, the
380 | messenger on horseback had exactly the same possessions as the King, the
381 | first Minister of State, or the richest merchant in London. So with the
382 | three passengers shut up in the narrow compass of one lumbering old mail
383 | coach; they were mysteries to one another, as complete as if each had
384 | been in his own coach and six, or his own coach and sixty, with the
385 | breadth of a county between him and the next.",
386 |
387 | *"The messenger rode back at an easy trot, stopping pretty often at
388 | ale-houses by the way to drink, but evincing a tendency to keep his
389 | own counsel, and to keep his hat cocked over his eyes. He had eyes that
390 | assorted very well with that decoration, being of a surface black, with
391 | no depth in the colour or form, and much too near together--as if they
392 | were afraid of being found out in something, singly, if they kept too
393 | far apart. They had a sinister expression, under an old cocked-hat like
394 | a three-cornered spittoon, and over a great muffler for the chin and
395 | throat, which descended nearly to the wearer's knees. When he stopped
396 | for drink, he moved this muffler with his left hand, only while he
397 | poured his liquor in with his right; as soon as that was done, he
398 | muffled again.",
399 |
400 | *"\"No, Jerry, no!\" said the messenger, harping on one theme as he rode.
401 | \"It wouldn't do for you, Jerry. Jerry, you honest tradesman, it wouldn't
402 | suit _your_ line of business! Recalled--! Bust me if I don't think he'd
403 | been a drinking!\"",
404 |
405 | *"His message perplexed his mind to that degree that he was fain, several
406 | times, to take off his hat to scratch his head. Except on the crown,
407 | which was raggedly bald, he had stiff, black hair, standing jaggedly all
408 | over it, and growing down hill almost to his broad, blunt nose. It was
409 | so like Smith's work, so much more like the top of a strongly spiked
410 | wall than a head of hair, that the best of players at leap-frog might
411 | have declined him, as the most dangerous man in the world to go over.",
412 |
413 | *"While he trotted back with the message he was to deliver to the night
414 | watchman in his box at the door of Tellson's Bank, by Temple Bar, who
415 | was to deliver it to greater authorities within, the shadows of the
416 | night took such shapes to him as arose out of the message, and took such
417 | shapes to the mare as arose out of _her_ private topics of uneasiness.
418 | They seemed to be numerous, for she shied at every shadow on the road.",
419 |
420 | *"What time, the mail-coach lumbered, jolted, rattled, and bumped upon
421 | its tedious way, with its three fellow-inscrutables inside. To whom,
422 | likewise, the shadows of the night revealed themselves, in the forms
423 | their dozing eyes and wandering thoughts suggested.",
424 |
425 | *"Tellson's Bank had a run upon it in the mail. As the bank
426 | passenger--with an arm drawn through the leathern strap, which did what
427 | lay in it to keep him from pounding against the next passenger,
428 | and driving him into his corner, whenever the coach got a special
429 | jolt--nodded in his place, with half-shut eyes, the little
430 | coach-windows, and the coach-lamp dimly gleaming through them, and the
431 | bulky bundle of opposite passenger, became the bank, and did a great
432 | stroke of business. The rattle of the harness was the chink of money,
433 | and more drafts were honoured in five minutes than even Tellson's, with
434 | all its foreign and home connection, ever paid in thrice the time. Then
435 | the strong-rooms underground, at Tellson's, with such of their valuable
436 | stores and secrets as were known to the passenger (and it was not a
437 | little that he knew about them), opened before him, and he went in among
438 | them with the great keys and the feebly-burning candle, and found them
439 | safe, and strong, and sound, and still, just as he had last seen them.",
440 |
441 | *"But, though the bank was almost always with him, and though the coach
442 | (in a confused way, like the presence of pain under an opiate) was
443 | always with him, there was another current of impression that never
444 | ceased to run, all through the night. He was on his way to dig some one
445 | out of a grave.",
446 |
447 | *"Now, which of the multitude of faces that showed themselves before him
448 | was the true face of the buried person, the shadows of the night did
449 | not indicate; but they were all the faces of a man of five-and-forty by
450 | years, and they differed principally in the passions they expressed,
451 | and in the ghastliness of their worn and wasted state. Pride, contempt,
452 | defiance, stubbornness, submission, lamentation, succeeded one another;
453 | so did varieties of sunken cheek, cadaverous colour, emaciated hands
454 | and figures. But the face was in the main one face, and every head was
455 | prematurely white. A hundred times the dozing passenger inquired of this
456 | spectre:",
457 |
458 | *"\"Buried how long?\"",
459 |
460 | *"The answer was always the same: \"Almost eighteen years.\"",
461 |
462 | *"\"You had abandoned all hope of being dug out?\"",
463 |
464 | *"\"Long ago.\"",
465 |
466 | *"\"You know that you are recalled to life?\"",
467 |
468 | *"\"They tell me so.\"",
469 |
470 | *"\"I hope you care to live?\"",
471 |
472 | *"\"I can't say.\"",
473 |
474 | *"\"Shall I show her to you? Will you come and see her?\"",
475 |
476 | *"The answers to this question were various and contradictory. Sometimes
477 | the broken reply was, \"Wait! It would kill me if I saw her too soon.\"
478 | Sometimes, it was given in a tender rain of tears, and then it was,
479 | \"Take me to her.\" Sometimes it was staring and bewildered, and then it
480 | was, \"I don't know her. I don't understand.\"",
481 |
482 | *"After such imaginary discourse, the passenger in his fancy would dig,
483 | and dig, dig--now with a spade, now with a great key, now with his
484 | hands--to dig this wretched creature out. Got out at last, with earth
485 | hanging about his face and hair, he would suddenly fan away to dust. The
486 | passenger would then start to himself, and lower the window, to get the
487 | reality of mist and rain on his cheek.",
488 |
489 | *"Yet even when his eyes were opened on the mist and rain, on the moving
490 | patch of light from the lamps, and the hedge at the roadside retreating
491 | by jerks, the night shadows outside the coach would fall into the train
492 | of the night shadows within. The real Banking-house by Temple Bar, the
493 | real business of the past day, the real strong rooms, the real express
494 | sent after him, and the real message returned, would all be there. Out
495 | of the midst of them, the ghostly face would rise, and he would accost
496 | it again.",
497 |
498 | *"\"Buried how long?\"",
499 |
500 | *"\"Almost eighteen years.\"",
501 |
502 | *"\"I hope you care to live?\"",
503 |
504 | *"\"I can't say.\"",
505 |
506 | *"Dig--dig--dig--until an impatient movement from one of the two
507 | passengers would admonish him to pull up the window, draw his arm
508 | securely through the leathern strap, and speculate upon the two
509 | slumbering forms, until his mind lost its hold of them, and they again
510 | slid away into the bank and the grave.",
511 |
512 | *"\"Buried how long?\"",
513 |
514 | *"\"Almost eighteen years.\"",
515 |
516 | *"\"You had abandoned all hope of being dug out?\"",
517 |
518 | *"\"Long ago.\"",
519 |
520 | *"The words were still in his hearing as just spoken--distinctly in
521 | his hearing as ever spoken words had been in his life--when the weary
522 | passenger started to the consciousness of daylight, and found that the
523 | shadows of the night were gone.",
524 |
525 | *"He lowered the window, and looked out at the rising sun. There was a
526 | ridge of ploughed land, with a plough upon it where it had been left
527 | last night when the horses were unyoked; beyond, a quiet coppice-wood,
528 | in which many leaves of burning red and golden yellow still remained
529 | upon the trees. Though the earth was cold and wet, the sky was clear,
530 | and the sun rose bright, placid, and beautiful.",
531 |
532 | *"\"Eighteen years!\" said the passenger, looking at the sun. \"Gracious
533 | Creator of day! To be buried alive for eighteen years!\"",
534 |
535 | *"... To be continued!"
536 |
537 | }
538 |
539 | msg_i = 0
540 | print_speed = 2.5 -- chars per frame; can be fractional
541 | print_speed_ctr = 0
542 |
543 | function new_printer()
544 | local printer = {
545 | cur_x = 0,
546 | cur_y = 0,
547 | cur_str = nil,
548 | cur_pos = nil,
549 | cur_line = nil,
550 | color = 7,
551 | paused = false
552 | }
553 |
554 | function printer:print(s, color)
555 | if (color ~= nil) self.color = color
556 | if self.cur_str ~= nil then
557 | self.cur_str = self.cur_str..s
558 | else
559 | self.cur_str = s
560 | self.cur_pos = 1
561 | self.cur_line = 0
562 | end
563 | end
564 |
565 | function printer:scroll(h)
566 | if self.cur_y > 95 then
567 | memcpy(0x6000, 0x6000 + h*64, (108-h)*64)
568 | else
569 | self.cur_y += h
570 | end
571 | rectfill(0, 108-h, 128, 108, 0)
572 | end
573 |
574 | function printer:update()
575 | local c, p
576 |
577 | rectfill(0, 108, 128, 128, 1)
578 | if self.paused then
579 | print('-- more --', 86, 112, 6)
580 | end
581 |
582 | if btnp(5) and self.paused then
583 | self.paused = false
584 | end
585 | if self.paused then
586 | return
587 | end
588 |
589 | if self.cur_str ~= nil then
590 | c = sub(self.cur_str, self.cur_pos, self.cur_pos)
591 | if c == " " then
592 | p = self.cur_pos + 1
593 | while (p <= #self.cur_str) and
594 | (sub(self.cur_str, p, p) ~= " ") do
595 | p += 1
596 | end
597 | if (p - self.cur_pos) * 4 + self.cur_x > 127 then
598 | self.cur_line +=1
599 | if self.cur_line >= 14 then
600 | self.paused = true
601 | self.cur_line = 0
602 | end
603 | self:scroll(7)
604 | self.cur_x = 0
605 | else
606 | self.cur_x += 4
607 | end
608 | else
609 | print(c, self.cur_x, self.cur_y, self.color)
610 | self.cur_x += 4
611 | end
612 | self.cur_pos += 1
613 | if self.cur_pos > #self.cur_str then
614 | self:scroll(9) -- end of paragraph scroll
615 | self.cur_x = 0
616 | self.cur_str = nil
617 | self.cur_pos = nil
618 | self.cur_line = nil
619 | end
620 | end
621 | end
622 |
623 | return printer
624 | end
625 |
626 | function _init()
627 | cls()
628 | printer = new_printer()
629 | end
630 |
631 | function _update()
632 | print_speed_ctr += print_speed
633 | while print_speed_ctr >= 1 do
634 | printer:update()
635 | print_speed_ctr -= 1
636 | end
637 |
638 | if printer.cur_str == nil and btnp(5) then
639 | printer:print(_t(msgs[msg_i+1]))
640 | msg_i = (msg_i + 1) % #msgs
641 | end
642 | end
643 |
644 | function _draw()
645 | rectfill(0,120,128,128,1)
646 | print(stat(0),0,120,7)
647 | end
648 |
--------------------------------------------------------------------------------
/tests/testdata/test_game.lua:
--------------------------------------------------------------------------------
1 | msgs = {
2 | "it was the best of times",
3 | "it was the worst of times",
4 | "some things happened",
5 | "then some more things happened",
6 | "but i don't really know what happened",
7 | "so i probably shouldn't say",
8 | "some things happened",
9 | "that's all i know",
10 | 'he stood up. "no," he said. ok.',
11 | 'then i (his brother) laughed.',
12 | '"uh, maybe?" i shook my head.',
13 | '"ok (or not)." i sighed.'
14 | }
15 | msg_i = #msgs - 1
16 |
17 | function _update()
18 | if btnp(0) then
19 | msg_i = (msg_i + 1) % #msgs
20 | end
21 | end
22 |
23 | function _draw()
24 | cls()
25 | print(_t(msgs[msg_i+1]), 0, 0, 7)
26 | end
27 |
--------------------------------------------------------------------------------
/tests/textlib_test.py:
--------------------------------------------------------------------------------
1 | import unittest
2 |
3 | from p8advent import textlib
4 |
5 |
6 | class TestTextLib(unittest.TestCase):
7 | def setUp(self):
8 | self.tl = textlib.TextLib(prefix_length=2)
9 |
10 | def test_encode_pscii(self):
11 | self.assertEqual(b'\x1e\x1f\x20\x21\x22\x23\x0f\x10\x11',
12 | textlib.encode_pscii('abcDEF123'))
13 |
14 | def test_encode_word(self):
15 | result = self.tl._encode_word('aardvark')
16 | self.assertEqual(b'\x80\x00', result)
17 | result = self.tl._encode_word('aaron')
18 | self.assertEqual(b'\x80\x01', result)
19 | result = self.tl._encode_word('baron')
20 | self.assertEqual(b'\x81\x00', result)
21 |
22 | def test_encode_word_shorter_than_prefix(self):
23 | result = self.tl._encode_word('a')
24 | self.assertEqual(b'\x80\x00', result)
25 |
26 | def test_encode_word_too_many_prefixes(self):
27 | try:
28 | for a in range(ord('a'), ord('z')):
29 | for b in range(ord('a'), ord('z')):
30 | w = chr(a) + chr(b) + 'x'
31 | self.tl._encode_word(w)
32 | self.fail()
33 | except textlib.TooManyWordsForPrefixError:
34 | pass
35 |
36 | def test_encode_word_too_many_suffixes(self):
37 | try:
38 | for a in range(ord('a'), ord('z')):
39 | for b in range(ord('a'), ord('z')):
40 | w = 'xx' + chr(a) + chr(b)
41 | self.tl._encode_word(w)
42 | self.fail()
43 | except textlib.TooManyWordsForPrefixError:
44 | pass
45 |
46 | def test_encode_string_id(self):
47 | self.assertEqual(' ', self.tl._encode_string_id(0))
48 | self.assertEqual('! ', self.tl._encode_string_id(1))
49 | self.assertEqual('" ', self.tl._encode_string_id(2))
50 | self.assertEqual('# ', self.tl._encode_string_id(3))
51 | self.assertEqual('% ', self.tl._encode_string_id(4))
52 | self.assertEqual('@ ', self.tl._encode_string_id(63))
53 | self.assertEqual(' ! ', self.tl._encode_string_id(64))
54 | self.assertEqual('!! ', self.tl._encode_string_id(65))
55 | self.assertEqual('"! ', self.tl._encode_string_id(66))
56 | self.assertEqual('@@ ', self.tl._encode_string_id(4095))
57 | self.assertEqual(' !', self.tl._encode_string_id(4096))
58 | self.assertEqual('! !', self.tl._encode_string_id(4097))
59 | self.assertEqual('@@1', self.tl._encode_string_id(65535))
60 |
61 | def test_encode_string(self):
62 | result = self.tl._encode_string('aardvark aaron baron')
63 | self.assertEqual(b'\x80\x00\x80\x01\x81\x00', result)
64 |
65 | result = self.tl._encode_string('"Aardvark? Aaron, baron."')
66 | self.assertEqual(b'\x02\x80\x00\x1d\x80\x01\x0a\x81\x00\x0c\x02',
67 | result)
68 |
69 | def test_id_for_string(self):
70 | result = self.tl.id_for_string('aardvark aaron baron')
71 | self.assertEqual(' ', result)
72 |
73 | result = self.tl.id_for_string('"Aardvark? Aaron, baron."')
74 | self.assertEqual('! ', result)
75 |
76 | result = self.tl.id_for_string('aardvark aaron baron')
77 | self.assertEqual(' ', result)
78 |
79 | def test_as_bytes(self):
80 | # Discard IDs, we just want to load the lib.
81 | self.tl.id_for_string('aardvark aaron baron')
82 | self.tl.id_for_string('"Aardvark? Aaron, baron."')
83 |
84 | result = self.tl.as_bytes()
85 | expected = (b'\x02\x02\x00' +
86 | b'\x09\x00\x0f\x00\x1a\x00' + # string jump table
87 | b'\x80\x00\x80\x01\x81\x00' + # string data
88 | b'\x02\x80\x00\x1d\x80\x01\x0a\x81\x00\x0c\x02' +
89 | b'\x1e\x00\x2b\x00' + # prefix jump table
90 | b'\x1e\x1e\x2f\x21\x33\x1e\x2f\x28\x00' + # lookup data
91 | b'\x2f\x2c\x2b\x00' +
92 | b'\x1f\x1e\x2f\x2c\x2b\x00')
93 | self.assertEqual(expected, result)
94 |
95 | def test_generate_lua(self):
96 | self.tl.id_for_string('aardvark aaron baron')
97 | self.tl.id_for_string('"Aardvark? Aaron, baron."')
98 |
99 | result = self.tl.generate_lua(text_start_addr=512)
100 | self.assertIn('local ta=512\n', result)
101 |
102 |
103 | if __name__ == '__main__':
104 | unittest.main()
105 |
--------------------------------------------------------------------------------