/ resource_pack / build.py
build.py
  1  from pathlib import Path
  2  from enum import Enum
  3  from pydantic import BaseModel
  4  from PIL import Image
  5  import json, datetime, os, subprocess, hashlib, io, shutil, collections, re
  6  
  7  HIDE_HEADERS = True
  8  HIDE_SKIPPED = True
  9  
 10  INPUT_PATH = Path("./content").resolve()
 11  BUILD_PATH = Path("./build"); BUILD_PATH.mkdir(parents=True,exist_ok=True); BUILD_PATH = BUILD_PATH.resolve()
 12  OUTPUT_PATH = Path("./dist"); OUTPUT_PATH.mkdir(parents=True,exist_ok=True); OUTPUT_PATH = OUTPUT_PATH.resolve()
 13  
 14  BLOCKSIZE = 1024**2
 15  
 16  BEDROCK_ID_MAP = {}
 17  
 18  class Platform(Enum):
 19      JAVA = "Java"
 20      BEDROCK = "Bedrock"
 21  
 22  class ResourcePack(BaseModel):
 23      name: str
 24      uuid: str
 25      uuid2: str
 26      platform: Platform
 27      
 28      buildpath: Path
 29      distpath: Path
 30  
 31      modified: bool = False
 32      
 33  def create_java_pack(name, buildpath, distpath) -> ResourcePack:
 34      print("Defined Pack:", name)
 35      if not HIDE_HEADERS: print("Build:", buildpath.relative_to(os.getcwd()))
 36      if not HIDE_HEADERS: print("Dist:", distpath.relative_to(os.getcwd()))
 37      if not HIDE_HEADERS: print("Platform:", Platform.JAVA)
 38  
 39      pack = ResourcePack(name=name, uuid="", uuid2="", platform=Platform.JAVA,
 40                          buildpath=buildpath, distpath=distpath)
 41  
 42      if not buildpath.exists(): buildpath.mkdir(parents=True, exist_ok=True)
 43  
 44      if not HIDE_HEADERS: print("Writing metadata...")
 45      with open(pack.buildpath / "pack.mcmeta", "w") as f:
 46          json.dump({
 47              "pack": {
 48                  "pack_format": 12,
 49                  "description": f"{name} | CCMCC Resources"
 50                  }
 51              }, f)
 52  
 53      if not HIDE_HEADERS: print("Done.\n")
 54      return pack
 55  
 56  def create_bedrock_pack(name, uuid, uuid2, buildpath, distpath) -> ResourcePack:
 57      print("Defined Pack:", name, f"({uuid} / {uuid2})")
 58      if not HIDE_HEADERS: print("Build:", buildpath.relative_to(os.getcwd()))
 59      if not HIDE_HEADERS: print("Dist:", distpath.relative_to(os.getcwd()))
 60      if not HIDE_HEADERS: print("Platform:", Platform.BEDROCK)
 61  
 62      pack = ResourcePack(name=name, uuid=uuid, uuid2=uuid2, platform=Platform.BEDROCK,
 63                          buildpath=buildpath, distpath=distpath)
 64  
 65      if not buildpath.exists(): buildpath.mkdir(parents=True, exist_ok=True)
 66  
 67      # Generate version number automatically
 68      now = datetime.datetime.now()
 69      # td = now - datetime.datetime(2019, 11, 17, 8, 0, 0)
 70      td = (now - datetime.datetime(now.year, 1, 1))
 71      major = now.year
 72      minor = td.days
 73      patch = td.seconds
 74  
 75      if not HIDE_HEADERS: print("Writing metadata...")
 76      with open(pack.buildpath / "manifest.json", "w") as f:
 77          json.dump({
 78              "format_version": 2,
 79              "header": {
 80                  "description": f"CCMCC Resources ({name})",
 81                  "name": "CCMCC Resources ({name})",
 82                  "uuid": uuid,
 83                  "version": [major, minor, patch],
 84                  "min_engine_version": [1, 19, 0]
 85                  },
 86              "modules": [
 87                  {
 88                      "description": "CCMCC Resources ({name})",
 89                      "type": "resources",
 90                      "uuid": uuid2,
 91                      "version": [major, minor, patch]
 92                      }
 93                  ]
 94              }, f)
 95  
 96      if not HIDE_HEADERS: print("Done.\n")
 97      return pack
 98  
 99  def save_pack_windows(pack):
100      print("Writing Pack:", pack.name, f"({pack.uuid} / {pack.uuid2})")
101      if not HIDE_HEADERS: print("Content:", pack.buildpath.relative_to(os.getcwd()))
102      if not HIDE_HEADERS: print("Target:", pack.distpath.relative_to(os.getcwd()))
103      EXECUTABLE = r"C:\Program Files\7-Zip\7z.exe" #r"D:\Program Files\7-Zip\7z.exe"
104      args = ["u", "-tzip", "-y", str(pack.distpath.resolve())]
105      for f in pack.buildpath.iterdir():
106          args.append(str(f))
107      if not HIDE_HEADERS: print("Command:", EXECUTABLE, " ".join(args))
108  
109      if not HIDE_HEADERS: print("Executing...")
110      result = subprocess.run([EXECUTABLE] + args)
111      if not HIDE_HEADERS: print("Completed with return code", result.returncode)
112      assert result.returncode == 0
113      if not HIDE_HEADERS: print("Done.\n")
114  
115  
116  class PackFileWriteManager:
117      def __init__(self, pack: ResourcePack, relpath: str, mode: str):
118          self.pack = pack
119          self.relpath = relpath
120          self.mode = mode
121  
122          self.fp = None
123  
124      def __enter__(self):
125          self.path = self.pack.buildpath / self.relpath
126          self.path.parent.mkdir(parents=True, exist_ok=True)
127  
128          if "b" in self.mode:
129              self.fp = io.BytesIO()
130          else:
131              self.fp = io.StringIO()
132  
133          return self.fp
134  
135      def __exit__(self, *args):
136          if args[0] != None:
137              return
138          
139          self.fp.seek(0)
140          data = self.fp.read()
141  
142          if type(data) is str: data = data.encode("utf-8")
143          
144          myhash = hashlib.sha1(data).hexdigest()
145  
146          if self.path.exists():
147              otherhasher = hashlib.sha1()
148              with open(self.path, "rb") as f:
149                  while (x := f.read(BLOCKSIZE)):
150                      otherhasher.update(x)
151              otherhash = otherhasher.hexdigest()
152          else:
153              otherhash = ""
154  
155          if myhash == otherhash:
156              if not HIDE_SKIPPED: print("Skipping", self.path, " (Not Modified)")
157          else:
158              print("Writing", self.path)
159              self.fp.seek(0)
160              with open(self.path, self.mode, newline=None if "b" in self.mode else "") as f:
161                  f.write(self.fp.read())
162              self.pack.modified = True
163  
164  def copy_into_pack(source: Path, pack: ResourcePack, dest: str):
165      target = pack.buildpath / dest
166  
167      sourcehasher = hashlib.sha1()
168      with open(source, "rb") as f:
169          while (x := f.read(BLOCKSIZE)): sourcehasher.update(x)
170      hash1 = sourcehasher.hexdigest()
171  
172      if target.exists():
173          desthasher = hashlib.sha1()
174          with open(target, "rb") as f:
175              while (x := f.read(BLOCKSIZE)): desthasher.update(x)
176          hash2 = desthasher.hexdigest()
177      else:
178          hash2 = ""
179          target.parent.mkdir(parents=True, exist_ok=True)
180  
181      if hash1 == hash2:
182          if not HIDE_SKIPPED: print("Skipping", target, "(Not Modified)")
183      else:
184          print("Copying", target)
185          target.unlink(missing_ok=True)
186          shutil.copy2(source, target)
187          pack.modified = True
188  
189  
190  def copy_music_bedrock(source: Path, pack: ResourcePack, dest: str, volume: float, pack_to_take_hash_from: ResourcePack):
191      target = pack.buildpath / dest
192  
193      sourcehasher = hashlib.sha1()
194      with open(source, "rb") as f:
195          while (x := f.read(BLOCKSIZE)): sourcehasher.update(x)
196      hash1 = sourcehasher.hexdigest()
197  
198      hash_target = pack_to_take_hash_from.buildpath / "assets/minecraft" / dest
199      if hash_target.exists():
200          desthasher = hashlib.sha1()
201          with open(hash_target, "rb") as f:
202              while (x := f.read(BLOCKSIZE)): desthasher.update(x)
203          hash2 = desthasher.hexdigest()
204      else:
205          hash2 = ""
206          target.parent.mkdir(parents=True, exist_ok=True)
207  
208      if hash1 == hash2:
209          if not HIDE_SKIPPED: print("Skipping", target, "(Not Modified)")
210      else:
211          print("Copying (volume=" + str(volume), target)
212          target.unlink(missing_ok=True)
213          #shutil.copy2(source, target)
214          os.system(f"ffmpeg -i \"{source}\" -filter:a \"volume={volume/2}\" -ar 22k \"{target}\"")
215          pack.modified = True
216      
217  
218  def get_bedrock_id(x):
219      return BEDROCK_ID_MAP.get(x, x)
220  
221  CONCRETE_RE = re.compile(r"(.+?)_(concrete(_.+)?)(\..*)")
222  TERACOTTA_RE = re.compile(r"(.+?)_((glazed_)?terracotta)")
223  def get_bedrock_block_path(x):
224      if "deepslate" in x and not "reinforced" in x:
225          return f"deepslate/{x}"
226      elif "candle" in x:
227          return f"candles/{x}"
228      elif "warped" in x or "crimson" in x:
229          return f"huge_fungus/{x}"
230      elif "golden" in x: return x.replace("golden", "gold")
231      elif "dried_kelp_block" in x: return x.replace("dried_kelp_block", "dried_kelp", 1)
232      elif (cm := CONCRETE_RE.match(x)): return cm.group(2) + "_" + cm.group(1) + cm.group(4)
233      elif (cm := TERACOTTA_RE.match(x)): return cm.group(2) + "_" + cm.group(1)
234      elif "dark_prismarine" in x: return x.replace("dark_prismarine", "prismarine_dark")
235      elif "prismarine" in x and not "bricks" in x: return x.replace("prismarine", "prismarine_rough")
236      elif "end_stone_bricks" in x: return x.replace("_stone", "")
237      elif "red_nether_bricks" in x: return x.replace("bricks", "brick")
238      elif "command_block_minecart" in x: return x.replace("command_block_minecart","minecart_command_block")
239      else:
240          return x
241  
242  
243  # Create Packs
244  if not HIDE_HEADERS: print()
245  jmain_pack = create_java_pack("Java Full", BUILD_PATH / "javafull", OUTPUT_PATH / "CCMCC2_java.zip")
246  bmusic_pack = create_bedrock_pack("Bedrock Music", "0e05de52-1647-11ed-832a-8627fd233021", "3a7a491c-1721-11ed-837f-8627fd233021", BUILD_PATH / "bdmusic", OUTPUT_PATH / "CCMCC2_bedrock_music.mcpack")
247  bother_pack = create_bedrock_pack("Bedrock Other", "23dc5450-1647-11ed-9db1-8627fd233021", "3cd8f60f-1721-11ed-850e-8627fd233021", BUILD_PATH / "bdother", OUTPUT_PATH / "CCMCC2_bedrock_other.mcpack")
248  ALL_PACKS = [jmain_pack, bmusic_pack, bother_pack]
249  
250  # Language
251  if not HIDE_HEADERS: print("Stage 1: Language files")
252  LANG_PATH = INPUT_PATH / "lang"
253  
254  for lang_file in LANG_PATH.iterdir():
255      lang_name = lang_file.with_suffix("").name
256      if not HIDE_HEADERS: print("Processing Language:",lang_name)
257  
258      # Process
259      with open(lang_file) as f:
260          data = json.load(f)
261  
262      block_names = {block_id: bname for block_id, bname in data.get("blocks", {}).items()}
263      item_names = {block_id: bname for block_id, bname in data.get("items", {}).items()}
264      #ccmcc_strings = {f"string.ccmcc.{sid}": fstr for sid, fstr in data.get("strings", {}).items()}
265      other_definitions = {definition: value for definition, value in data.get("other", {}).items()}
266  
267      # Write java
268      if not HIDE_HEADERS: print("Writing java...")
269      with PackFileWriteManager(jmain_pack, f"assets/minecraft/lang/{lang_name.lower()}.json", "w") as java_file:
270          data = {}
271          for id, bname in block_names.items():
272              data[f"block.minecraft.{id}"] = bname
273          for id, bname in item_names.items():
274              data[f"item.minecraft.{id}"] = bname
275          #data.update(ccmcc_strings)
276          data.update(other_definitions)
277          json.dump(data, java_file)
278              
279  
280      # Write bedrock
281      if not HIDE_HEADERS: print("Writing bedrock...")
282      with PackFileWriteManager(bother_pack, f"texts/{lang_name}.lang", "w") as bedrock_file:
283          for id, bname in block_names.items():
284              bedrock_file.write(f"tile.{get_bedrock_id(id)}.name={bname}\n")
285          for id, bname in item_names.items():
286              bedrock_file.write(f"item.{get_bedrock_id(id)}.name={bname}\n")
287          #for k, v in ccmcc_strings.items(): bedrock_file.write(f"{k}={v}\n")
288          for k, v in other_definitions.items(): bedrock_file.write(f"{k}={v}\n")
289  
290  # Items
291  if not HIDE_HEADERS: print()
292  if not HIDE_HEADERS: print("Stage 2: Item Textures")
293  ITEM_PATH = INPUT_PATH / "item"
294  
295  for item_tx in ITEM_PATH.iterdir():
296      copy_into_pack(item_tx, jmain_pack, f"assets/minecraft/textures/item/{item_tx.name}")
297      copy_into_pack(item_tx, bother_pack, f"textures/items/{get_bedrock_block_path(item_tx.name)}")
298  
299  # Armour Models
300  if not HIDE_HEADERS: print()
301  if not HIDE_HEADERS: print("Stage 3: Armour Models")
302  ARMOUR_PATH = INPUT_PATH / "armour"
303  
304  for arm in ARMOUR_PATH.iterdir():
305      copy_into_pack(arm, jmain_pack, f"assets/minecraft/textures/models/armor/{arm.name}")
306      copy_into_pack(arm, bother_pack, f"textures/models/armor/{arm.name.replace('layer_','')}")
307  
308  # Blocks
309  if not HIDE_HEADERS: print()
310  if not HIDE_HEADERS: print("Stage 4: Block Textures")
311  BLOCK_PATH = INPUT_PATH / "block"
312  
313  for blk in BLOCK_PATH.iterdir():
314      copy_into_pack(blk, jmain_pack, f"assets/minecraft/textures/block/{blk.name}")
315  
316      if blk.name.startswith("dried_kelp_side"):
317          # Manual override for kelp block
318          copy_into_pack(blk, bother_pack, f"textures/blocks/dried_kelp_side_a{blk.suffix}")
319          copy_into_pack(blk, bother_pack, f"textures/blocks/dried_kelp_side_b{blk.suffix}")
320      else:
321          copy_into_pack(blk, bother_pack, f"textures/blocks/{get_bedrock_block_path(blk.name)}")
322  
323  # Symbols
324  if not HIDE_HEADERS: print()
325  if not HIDE_HEADERS: print("Stage 5: Symbols")
326  SYMBOL_PATH = INPUT_PATH / "symbol"
327  
328  jsymbols_used = {}
329  bsymbols_used = {}
330  BEDROCK_PREFIX = "bedrock_"
331  for symbol in SYMBOL_PATH.iterdir():
332      codepoint = symbol.with_suffix("").name
333      if codepoint.startswith(BEDROCK_PREFIX): continue
334      
335      # Java
336      jsymbols_used[int(codepoint, base=16)] = symbol
337      copy_into_pack(symbol, jmain_pack, f"assets/minecraft/textures/customsymbol/{int(codepoint, base=16)}.png")
338  
339      # Bedrock
340      firsthalf = codepoint[:2]
341      secondhalf = int(codepoint[2:], base=16)
342      if firsthalf not in bsymbols_used: bsymbols_used[firsthalf] = {}
343  
344      bedrock_name = symbol.with_name(BEDROCK_PREFIX + symbol.name)
345      if bedrock_name.exists(): bsymbols_used[firsthalf][secondhalf] = bedrock_name
346      else: bsymbols_used[firsthalf][secondhalf] = symbol
347  
348  # Symbols: Finalise Java
349  if not HIDE_HEADERS: print("Writing Java metadata...")
350  with PackFileWriteManager(jmain_pack, "assets/minecraft/font/default.json", "w") as f:
351      data = {"providers": []
352          }
353  
354      for codepoint, symbol in jsymbols_used.items():
355          data["providers"].append({
356              "type": "bitmap",
357              "file": f"minecraft:customsymbol/{codepoint}.png",
358              "ascent": 7,
359              "height": 7,
360              "chars": [chr(codepoint)]
361              })
362      
363      json.dump(data, f)
364  
365  # Symbols: Finalise Bedrock
366  PIXELS_PER_GLYPH = 32
367  TARGET_SIZE = (7, 7)
368  for section, data in bsymbols_used.items():
369      if not HIDE_HEADERS: print(f"Writing Bedrock glyphs... ({section})")
370      file_path = f"font/glyph_{section}.png"
371  
372      img = Image.new("RGBA", (PIXELS_PER_GLYPH*16, PIXELS_PER_GLYPH*16), color=(0,0,0,0))
373  
374      # Place glyphs
375      for index, file in data.items():
376          xidx = index % 16
377          yidx = index // 16
378  
379          xoffset = xidx * PIXELS_PER_GLYPH
380          yoffset = yidx * PIXELS_PER_GLYPH
381          
382          with Image.open(file) as glyph:
383              if glyph.size == TARGET_SIZE: glyph_scaled = glyph
384              else: glyph_scaled = glyph.resize(TARGET_SIZE)
385  
386              # Centre glyph
387              xoffset += (PIXELS_PER_GLYPH - glyph_scaled.size[0])//2
388              yoffset += (PIXELS_PER_GLYPH - glyph_scaled.size[1])//2
389  
390              # Place glyph on image
391              img.paste(glyph_scaled, (xoffset, yoffset))
392  
393      # Write
394      with PackFileWriteManager(bother_pack, file_path, "wb") as f:
395          img.save(f, "png")
396      
397  
398  # Sounds
399  if not HIDE_HEADERS: print()
400  if not HIDE_HEADERS: print("Stage 6: Sounds")
401  SOUND_PATH = INPUT_PATH / "sound"
402  
403  USELESS_MC_MUSIC = ["music.game", "music.overworld.dripstone_caves", "music.overworld.frozen_peaks", "music.overworld.grove", "music.jagged_peaks", "music.lush_caves", "music.meadow",
404                      "music.snowy_slopes", "music.stony_peaks", "music.under_water", "music.creative", "music.credits", "music.end", "music.dragon"]
405  
406  jsounddata = {x: {"sounds": [], "replace": True} for x in USELESS_MC_MUSIC}
407  bmusicdata = {x: {"category": "neutral", "sounds": []} for x in USELESS_MC_MUSIC}
408  bsounddata = {}
409  
410  with open(SOUND_PATH / "sounds.json") as f:
411      sounds = json.load(f)
412  
413  for sound in sounds:
414      stype = sound["type"]  # music, player, or world (not implemented)
415      assert stype in ["music", "player"]
416  
417      s_id = sound["id"]  # ID for the sound
418      s_volume = sound["volume"]
419  
420      if stype == "music":
421          intro_file = sound["intro"]
422          if intro_file is not None:
423              intro_file = SOUND_PATH / intro_file
424              copy_music_bedrock(intro_file, bmusic_pack, "sounds/" + sound["intro"], s_volume, jmain_pack)
425              
426              copy_into_pack(intro_file, jmain_pack, "assets/minecraft/sounds/" + sound["intro"])
427              jsounddata[s_id + ".intro"] = {"sounds": [{
428                  "name": sound["intro"].rpartition(".")[0],
429                  "stream": True,
430                  "volume": s_volume
431                  }]}
432              
433              #copy_into_pack(intro_file, bmusic_pack, "sounds/" + sound["intro"])
434              bmusicdata[s_id + ".intro"] = {
435                  "category": "music",
436                  "sounds": ["sounds/" + sound["intro"].rpartition(".")[0]],
437                  "stream": True,
438                  "volume": 1#s_volume
439                  }
440          
441          loop_file = sound["loop"]
442          if loop_file is not None:
443              loop_file = SOUND_PATH / loop_file
444              copy_music_bedrock(loop_file, bmusic_pack, "sounds/" + sound["loop"], s_volume, jmain_pack)
445              
446              copy_into_pack(loop_file, jmain_pack, "assets/minecraft/sounds/" + sound["loop"])
447              jsounddata[s_id + ".loop"] = {"sounds": [{
448                  "name": sound["loop"].rpartition(".")[0],
449                  "stream": True,
450                  "volume": s_volume
451                  }]}
452              
453              #copy_into_pack(loop_file, bmusic_pack, "sounds/" + sound["loop"])
454              bmusicdata[s_id + ".loop"] = {
455                  "category": "music",
456                  "sounds": ["sounds/" + sound["loop"].rpartition(".")[0]],
457                  "stream": True,
458                  "volume": 1#s_volume
459                  }
460      else:
461          sound_file = SOUND_PATH / sound["file"]
462          copy_into_pack(sound_file, jmain_pack, "assets/minecraft/sounds/" + sound["file"])
463          jsounddata[s_id] = {"sounds": [{
464              "name": sound["file"].rpartition(".")[0],
465              "preload": True,
466              "attenuation_distance": 128,
467              "volume": s_volume
468              }]}
469          
470          copy_into_pack(sound_file, bother_pack, "sounds/" + sound["file"])
471          bsounddata[s_id] = {
472              "category": "record",
473              "sounds": ["sounds/" + sound["file"].rpartition(".")[0]],
474              "preload": True,
475              "is3d": False,
476              "is3D": False,
477              "interruptible": False,
478              "min_distance": 128.0,
479              "max_distance": 1024.0,
480              "volume": s_volume
481              }
482          
483  
484  # Sounds: Write Java data
485  if not HIDE_HEADERS: print("Writing Java data...")
486  with PackFileWriteManager(jmain_pack, "assets/minecraft/sounds.json", "w") as f:
487      json.dump(jsounddata, f)
488  
489  # Sounds: Write bedrock data
490  if not HIDE_HEADERS: print("Writing bedrock data...")
491  # "format_version": "1.14.0", "sound_definitions": {}
492  with PackFileWriteManager(bmusic_pack, "sounds/sound_definitions.json", "w") as f:
493      json.dump({"format_version": "1.14.0", "sound_definitions": bmusicdata}, f)
494  with PackFileWriteManager(bother_pack, "sounds/sound_definitions.json", "w") as f:
495      json.dump({"format_version": "1.14.0", "sound_definitions": bsounddata}, f)
496  
497  
498  # Other Includes
499  if not HIDE_HEADERS: print("Stage 255: Other Includes")
500  JINCLUDE_PATH = INPUT_PATH / "include" / "jmain"
501  BINCLUDE_PATH = INPUT_PATH / "include" / "bother"
502  
503  def walk(start):
504      walk_queue = collections.deque([start])
505      while walk_queue:
506          root = walk_queue.popleft()
507          dirs = []
508          files = []
509          for target in root.iterdir():
510              if target.is_dir():
511                  dirs.append(target)
512  
513              if target.is_file():
514                  files.append(target)
515  
516          yield root, dirs, files
517          walk_queue.extend(dirs)
518  
519  if not HIDE_HEADERS: print("Java Includes:")
520  for root, dirs, files in walk(JINCLUDE_PATH):
521      for f in files:
522          if f.suffix == ".pdn": continue
523          copy_into_pack(f, jmain_pack, str(f.relative_to(JINCLUDE_PATH)))
524  
525  if not HIDE_HEADERS: print("Bedrock Includes:")
526  for root, dirs, files in walk(BINCLUDE_PATH):
527      for f in files:
528          if f.suffix == ".pdn": continue
529          copy_into_pack(f, bother_pack, str(f.relative_to(BINCLUDE_PATH)))
530  
531  # Save Packs
532  if not HIDE_HEADERS: print()
533  for pack in ALL_PACKS:
534      if not pack.modified:
535          print(f"Skipping {pack.name}: Not modified.")
536          if not HIDE_HEADERS: print()
537          continue
538  
539      save_pack_windows(pack)
540  
541      if pack.platform is Platform.JAVA:
542          # Provide SHA-1
543          with open(pack.distpath, "rb") as f:
544              sha = hashlib.sha1()
545              while (x := f.read(BLOCKSIZE)): sha.update(x)
546              digest = sha.hexdigest()
547  
548          with open(str(pack.distpath) + "-sha1.txt", "w") as f:
549              f.write(digest)
550  
551  print("*"*26)
552  print(" "*6 + "BUILD COMPLETE" + " "*6)
553  print("*"*26)
554  input("Press ENTER to exit.")