[util/design] Add OTP preload image generator script

This adds an OTP preload image generator script that builds on top of
the already existing OTP memory map and LC state generation scripts and
classes.

It basically reads an OTP image configuration that defines the values of
specific OTP memory map items. Together with the memory map and life
cycle definition Hjson files, these values are then transformed into a
memory hexfile suitable for preloading simulation and FPGA emulation
runs. If needed, this script also scrambles the partitions and
calculates partition digests.

The generated hexfile is compatible with the Verilog $readmemh
function.

Signed-off-by: Michael Schaffner <msf@opentitan.org>
diff --git a/hw/ip/lc_ctrl/data/lc_ctrl_state.hjson b/hw/ip/lc_ctrl/data/lc_ctrl_state.hjson
index 5e1a17e..728499b 100644
--- a/hw/ip/lc_ctrl/data/lc_ctrl_state.hjson
+++ b/hw/ip/lc_ctrl/data/lc_ctrl_state.hjson
@@ -67,7 +67,7 @@
     // Life cycle state transition counter definition.
     // From least to most significant OTP word in ascending order.
     lc_cnt : {
-        RAW : [ '0',  '0',  '0',  '0', '0',  '0',  '0',   '0',  '0',  '0',   '0',   '0',   '0',   '0',   '0',   '0'],
+        0   : [ '0',  '0',  '0',  '0', '0',  '0',  '0',   '0',  '0',  '0',   '0',   '0',   '0',   '0',   '0',   '0'],
         1   : ['D0', 'C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'C7', 'C8', 'C9', 'C10', 'C11', 'C12', 'C13', 'C14', 'C15'],
         2   : ['D0', 'D1', 'C2', 'C3', 'C4', 'C5', 'C6', 'C7', 'C8', 'C9', 'C10', 'C11', 'C12', 'C13', 'C14', 'C15'],
         3   : ['D0', 'D1', 'D2', 'C3', 'C4', 'C5', 'C6', 'C7', 'C8', 'C9', 'C10', 'C11', 'C12', 'C13', 'C14', 'C15'],
diff --git a/hw/ip/lc_ctrl/dv/env/lc_ctrl_env_pkg.sv b/hw/ip/lc_ctrl/dv/env/lc_ctrl_env_pkg.sv
index bf3e68a..512b799 100644
--- a/hw/ip/lc_ctrl/dv/env/lc_ctrl_env_pkg.sv
+++ b/hw/ip/lc_ctrl/dv/env/lc_ctrl_env_pkg.sv
@@ -150,7 +150,7 @@
 
   function automatic int dec_lc_cnt(lc_cnt_e curr_cnt);
     case (curr_cnt)
-      LcCntRaw: return 0;
+      LcCnt0  : return 0;
       LcCnt1  : return 1;
       LcCnt2  : return 2;
       LcCnt3  : return 3;
diff --git a/hw/ip/lc_ctrl/dv/env/lc_ctrl_if.sv b/hw/ip/lc_ctrl/dv/env/lc_ctrl_if.sv
index 4b036fc..a6e132c 100644
--- a/hw/ip/lc_ctrl/dv/env/lc_ctrl_if.sv
+++ b/hw/ip/lc_ctrl/dv/env/lc_ctrl_if.sv
@@ -38,7 +38,7 @@
   lc_flash_rma_seed_t flash_rma_seed_o;
 
   task automatic init(lc_state_e lc_state = LcStRaw,
-                      lc_cnt_e   lc_cnt = LcCntRaw,
+                      lc_cnt_e   lc_cnt = LcCnt0,
                       lc_tx_t    clk_byp_ack = Off,
                       lc_tx_t    flash_rma_ack = Off);
     otp_i.valid = 1;
diff --git a/hw/ip/lc_ctrl/dv/env/seq_lib/lc_ctrl_base_vseq.sv b/hw/ip/lc_ctrl/dv/env/seq_lib/lc_ctrl_base_vseq.sv
index 74c8ddc..68b5077 100644
--- a/hw/ip/lc_ctrl/dv/env/seq_lib/lc_ctrl_base_vseq.sv
+++ b/hw/ip/lc_ctrl/dv/env/seq_lib/lc_ctrl_base_vseq.sv
@@ -17,7 +17,7 @@
   rand lc_ctrl_state_pkg::lc_cnt_e   lc_cnt;
 
   constraint lc_cnt_c {
-    (lc_state != LcStRaw) -> (lc_cnt != LcCntRaw);
+    (lc_state != LcStRaw) -> (lc_cnt != LcCnt0);
   }
 
   `uvm_object_new
@@ -45,7 +45,7 @@
       `DV_CHECK_RANDOMIZE_FATAL(this)
     end else begin
       lc_state = LcStRaw;
-      lc_cnt = LcCntRaw;
+      lc_cnt = LcCnt0;
     end
     cfg.lc_ctrl_vif.init(lc_state, lc_cnt);
     wait(cfg.pwr_lc_vif.pins[LcPwrDoneRsp] == 1);
diff --git a/hw/ip/lc_ctrl/dv/env/seq_lib/lc_ctrl_common_vseq.sv b/hw/ip/lc_ctrl/dv/env/seq_lib/lc_ctrl_common_vseq.sv
index 6e4d585..654230d 100644
--- a/hw/ip/lc_ctrl/dv/env/seq_lib/lc_ctrl_common_vseq.sv
+++ b/hw/ip/lc_ctrl/dv/env/seq_lib/lc_ctrl_common_vseq.sv
@@ -12,7 +12,7 @@
 
   constraint lc_init_c {
    lc_state == LcStRaw;
-   lc_cnt   == LcCntRaw;
+   lc_cnt   == LcCnt0;
   }
 
   virtual task body();
diff --git a/hw/ip/lc_ctrl/rtl/lc_ctrl_state_decode.sv b/hw/ip/lc_ctrl/rtl/lc_ctrl_state_decode.sv
index e6a3ae2..781560a 100644
--- a/hw/ip/lc_ctrl/rtl/lc_ctrl_state_decode.sv
+++ b/hw/ip/lc_ctrl/rtl/lc_ctrl_state_decode.sv
@@ -72,7 +72,7 @@
         endcase // lc_state_i
 
         unique case (lc_cnt_i)
-          LcCntRaw: dec_lc_cnt_o = 5'd0;
+          LcCnt0:   dec_lc_cnt_o = 5'd0;
           LcCnt1:   dec_lc_cnt_o = 5'd1;
           LcCnt2:   dec_lc_cnt_o = 5'd2;
           LcCnt3:   dec_lc_cnt_o = 5'd3;
@@ -100,7 +100,7 @@
 
         // Require that any non-raw state has a valid, nonzero
         // transition count.
-        if (lc_state_i != LcStRaw && lc_cnt_i == LcCntRaw) begin
+        if (lc_state_i != LcStRaw && lc_cnt_i == LcCnt0) begin
           state_invalid_error_o = 1'b1;
         end
 
diff --git a/hw/ip/lc_ctrl/rtl/lc_ctrl_state_pkg.sv b/hw/ip/lc_ctrl/rtl/lc_ctrl_state_pkg.sv
index 1e6b1db..71d4438 100644
--- a/hw/ip/lc_ctrl/rtl/lc_ctrl_state_pkg.sv
+++ b/hw/ip/lc_ctrl/rtl/lc_ctrl_state_pkg.sv
@@ -200,23 +200,23 @@
   } lc_id_state_e;
 
   typedef enum logic [LcCountWidth-1:0] {
-    LcCntRaw = {ZRO, ZRO, ZRO, ZRO, ZRO, ZRO, ZRO, ZRO, ZRO, ZRO, ZRO, ZRO, ZRO, ZRO, ZRO, ZRO},
-    LcCnt1   = {C15, C14, C13, C12, C11, C10,  C9,  C8,  C7,  C6,  C5,  C4,  C3,  C2,  C1,  D0},
-    LcCnt2   = {C15, C14, C13, C12, C11, C10,  C9,  C8,  C7,  C6,  C5,  C4,  C3,  C2,  D1,  D0},
-    LcCnt3   = {C15, C14, C13, C12, C11, C10,  C9,  C8,  C7,  C6,  C5,  C4,  C3,  D2,  D1,  D0},
-    LcCnt4   = {C15, C14, C13, C12, C11, C10,  C9,  C8,  C7,  C6,  C5,  C4,  D3,  D2,  D1,  D0},
-    LcCnt5   = {C15, C14, C13, C12, C11, C10,  C9,  C8,  C7,  C6,  C5,  D4,  D3,  D2,  D1,  D0},
-    LcCnt6   = {C15, C14, C13, C12, C11, C10,  C9,  C8,  C7,  C6,  D5,  D4,  D3,  D2,  D1,  D0},
-    LcCnt7   = {C15, C14, C13, C12, C11, C10,  C9,  C8,  C7,  D6,  D5,  D4,  D3,  D2,  D1,  D0},
-    LcCnt8   = {C15, C14, C13, C12, C11, C10,  C9,  C8,  D7,  D6,  D5,  D4,  D3,  D2,  D1,  D0},
-    LcCnt9   = {C15, C14, C13, C12, C11, C10,  C9,  D8,  D7,  D6,  D5,  D4,  D3,  D2,  D1,  D0},
-    LcCnt10  = {C15, C14, C13, C12, C11, C10,  D9,  D8,  D7,  D6,  D5,  D4,  D3,  D2,  D1,  D0},
-    LcCnt11  = {C15, C14, C13, C12, C11, D10,  D9,  D8,  D7,  D6,  D5,  D4,  D3,  D2,  D1,  D0},
-    LcCnt12  = {C15, C14, C13, C12, D11, D10,  D9,  D8,  D7,  D6,  D5,  D4,  D3,  D2,  D1,  D0},
-    LcCnt13  = {C15, C14, C13, D12, D11, D10,  D9,  D8,  D7,  D6,  D5,  D4,  D3,  D2,  D1,  D0},
-    LcCnt14  = {C15, C14, D13, D12, D11, D10,  D9,  D8,  D7,  D6,  D5,  D4,  D3,  D2,  D1,  D0},
-    LcCnt15  = {C15, D14, D13, D12, D11, D10,  D9,  D8,  D7,  D6,  D5,  D4,  D3,  D2,  D1,  D0},
-    LcCnt16  = {D15, D14, D13, D12, D11, D10,  D9,  D8,  D7,  D6,  D5,  D4,  D3,  D2,  D1,  D0}
+    LcCnt0  = {ZRO, ZRO, ZRO, ZRO, ZRO, ZRO, ZRO, ZRO, ZRO, ZRO, ZRO, ZRO, ZRO, ZRO, ZRO, ZRO},
+    LcCnt1  = {C15, C14, C13, C12, C11, C10,  C9,  C8,  C7,  C6,  C5,  C4,  C3,  C2,  C1,  D0},
+    LcCnt2  = {C15, C14, C13, C12, C11, C10,  C9,  C8,  C7,  C6,  C5,  C4,  C3,  C2,  D1,  D0},
+    LcCnt3  = {C15, C14, C13, C12, C11, C10,  C9,  C8,  C7,  C6,  C5,  C4,  C3,  D2,  D1,  D0},
+    LcCnt4  = {C15, C14, C13, C12, C11, C10,  C9,  C8,  C7,  C6,  C5,  C4,  D3,  D2,  D1,  D0},
+    LcCnt5  = {C15, C14, C13, C12, C11, C10,  C9,  C8,  C7,  C6,  C5,  D4,  D3,  D2,  D1,  D0},
+    LcCnt6  = {C15, C14, C13, C12, C11, C10,  C9,  C8,  C7,  C6,  D5,  D4,  D3,  D2,  D1,  D0},
+    LcCnt7  = {C15, C14, C13, C12, C11, C10,  C9,  C8,  C7,  D6,  D5,  D4,  D3,  D2,  D1,  D0},
+    LcCnt8  = {C15, C14, C13, C12, C11, C10,  C9,  C8,  D7,  D6,  D5,  D4,  D3,  D2,  D1,  D0},
+    LcCnt9  = {C15, C14, C13, C12, C11, C10,  C9,  D8,  D7,  D6,  D5,  D4,  D3,  D2,  D1,  D0},
+    LcCnt10 = {C15, C14, C13, C12, C11, C10,  D9,  D8,  D7,  D6,  D5,  D4,  D3,  D2,  D1,  D0},
+    LcCnt11 = {C15, C14, C13, C12, C11, D10,  D9,  D8,  D7,  D6,  D5,  D4,  D3,  D2,  D1,  D0},
+    LcCnt12 = {C15, C14, C13, C12, D11, D10,  D9,  D8,  D7,  D6,  D5,  D4,  D3,  D2,  D1,  D0},
+    LcCnt13 = {C15, C14, C13, D12, D11, D10,  D9,  D8,  D7,  D6,  D5,  D4,  D3,  D2,  D1,  D0},
+    LcCnt14 = {C15, C14, D13, D12, D11, D10,  D9,  D8,  D7,  D6,  D5,  D4,  D3,  D2,  D1,  D0},
+    LcCnt15 = {C15, D14, D13, D12, D11, D10,  D9,  D8,  D7,  D6,  D5,  D4,  D3,  D2,  D1,  D0},
+    LcCnt16 = {D15, D14, D13, D12, D11, D10,  D9,  D8,  D7,  D6,  D5,  D4,  D3,  D2,  D1,  D0}
   } lc_cnt_e;
 
   // Decoded life cycle state, used to interface with CSRs and TAP.
diff --git a/hw/ip/lc_ctrl/rtl/lc_ctrl_state_transition.sv b/hw/ip/lc_ctrl/rtl/lc_ctrl_state_transition.sv
index b31773f..ceb82e6 100644
--- a/hw/ip/lc_ctrl/rtl/lc_ctrl_state_transition.sv
+++ b/hw/ip/lc_ctrl/rtl/lc_ctrl_state_transition.sv
@@ -43,7 +43,7 @@
       // In this state, the life cycle counter is incremented.
       // Throw an error if the counter is already maxed out.
       unique case (lc_cnt_i)
-        LcCntRaw: next_lc_cnt_o = LcCnt1;
+        LcCnt0:   next_lc_cnt_o = LcCnt1;
         LcCnt1:   next_lc_cnt_o = LcCnt2;
         LcCnt2:   next_lc_cnt_o = LcCnt3;
         LcCnt3:   next_lc_cnt_o = LcCnt4;
diff --git a/hw/ip/otp_ctrl/data/otp_ctrl_img_dev.hjson b/hw/ip/otp_ctrl/data/otp_ctrl_img_dev.hjson
new file mode 100644
index 0000000..034ca94
--- /dev/null
+++ b/hw/ip/otp_ctrl/data/otp_ctrl_img_dev.hjson
@@ -0,0 +1,114 @@
+// Copyright lowRISC contributors.
+// Licensed under the Apache License, Version 2.0, see LICENSE for details.
+// SPDX-License-Identifier: Apache-2.0
+//
+// Use the gen-otp-img.py script to convert this configuration into
+// a hex file for preloading the OTP in FPGA synthesis or simulation.
+//
+
+{
+    // Seed to be used for generation of partition randomized values.
+    // Can be overridden on the command line with the --seed switch.
+    seed: 01931961561863975174
+
+    // The partition and item names must correspond with the OTP memory map.
+    partitions: [
+        {
+            name:  "CREATOR_SW_CFG",
+            items: [
+                {
+                    name:  "CREATOR_SW_CFG_CONTENT",
+                    value: "0x0",
+                }
+                {
+                    name:  "CREATOR_SW_CFG_DIGEST",
+                    value: "0x0",
+                }
+            ],
+        }
+        {
+            name:  "OWNER_SW_CFG",
+            items: [
+                {
+                    name:  "OWNER_SW_CFG_CONTENT",
+                    value: "0x0"
+                }
+                {
+                    name:  "OWNER_SW_CFG_DIGEST",
+                    value: "0x0",
+                }
+            ],
+        }
+        {
+            name:  "HW_CFG",
+            // If set to true, this computes the HW digest value
+            // and locks the partition.
+            lock:  "True",
+            items: [
+                {
+                    name:  "DEVICE_ID",
+                    value: "<random>",
+                }
+            ],
+        }
+        {
+            name:  "SECRET0",
+            lock:  "True",
+            items: [
+                {
+                    name:  "TEST_UNLOCK_TOKEN",
+                    value: "<random>",
+                }
+                {
+                    name:  "TEST_EXIT_TOKEN",
+                    value: "<random>",
+                }
+            ],
+        }
+        {
+            name:  "SECRET1",
+            lock:  "True",
+            items: [
+                {
+                    name:  "FLASH_ADDR_KEY_SEED",
+                    value: "<random>",
+                }
+                {
+                    name:  "FLASH_DATA_KEY_SEED",
+                    value: "<random>",
+                }
+                {
+                    name:  "SRAM_DATA_KEY_SEED",
+                    value: "<random>",
+                }
+            ],
+        }
+        {
+            name:  "SECRET2",
+            lock:  "False",
+            items: [
+                {
+                    name:  "RMA_TOKEN",
+                    value: "<random>",
+                }
+                {
+                    name:  "CREATOR_ROOT_KEY_SHARE0",
+                    value: "<random>",
+                }
+                {
+                    name:  "CREATOR_ROOT_KEY_SHARE1",
+                    value: "<random>",
+                }
+            ],
+        }
+        {
+            name:  "LIFE_CYCLE",
+            // Can be one of the following strings:
+            // RAW, TEST_UNLOCKED0-3, TEST_LOCKED0-2, DEV, PROD, PROD_END, RMA, SCRAP
+            state: "DEV",
+            // Can range from 0 to 16.
+            // Note that a value of 0 is only permissible in RAW state.
+            count: "5"
+        }
+    ]
+}
diff --git a/hw/ip/otp_ctrl/data/otp_ctrl_img_raw.hjson b/hw/ip/otp_ctrl/data/otp_ctrl_img_raw.hjson
new file mode 100644
index 0000000..27a1663
--- /dev/null
+++ b/hw/ip/otp_ctrl/data/otp_ctrl_img_raw.hjson
@@ -0,0 +1,36 @@
+// Copyright lowRISC contributors.
+// Licensed under the Apache License, Version 2.0, see LICENSE for details.
+// SPDX-License-Identifier: Apache-2.0
+//
+// Use the gen-otp-img.py script to convert this configuration into
+// a hex file for preloading the OTP in FPGA synthesis or simulation.
+//
+
+{
+    // Seed to be used for generation of partition randomized values.
+    // Can be overridden on the command line with the --seed switch.
+    seed: 01931961561863975174
+
+    // The partition and item names must correspond with the OTP memory map.
+    partitions: [
+        {
+            name:  "SECRET1",
+            lock:  "False",
+            items: [
+                {
+                    name:  "FLASH_DATA_KEY_SEED",
+                    value: "<random>",
+                }
+            ],
+        }
+        {
+            name:  "LIFE_CYCLE",
+            // Can be one of the following strings:
+            // RAW, TEST_UNLOCKED0-3, TEST_LOCKED0-2, DEV, PROD, PROD_END, RMA, SCRAP
+            state: "RAW",
+            // Can range from 0 to 16.
+            // Note that a value of 0 is only permissible in RAW state.
+            count: 0
+        }
+    ]
+}
diff --git a/hw/ip/otp_ctrl/rtl/otp_ctrl_pkg.sv b/hw/ip/otp_ctrl/rtl/otp_ctrl_pkg.sv
index 3408c84..0407147 100644
--- a/hw/ip/otp_ctrl/rtl/otp_ctrl_pkg.sv
+++ b/hw/ip/otp_ctrl/rtl/otp_ctrl_pkg.sv
@@ -106,7 +106,7 @@
     valid: 1'b1,
     error: 1'b0,
     state: lc_ctrl_state_pkg::LcStRaw,
-    count: lc_ctrl_state_pkg::LcCntRaw,
+    count: lc_ctrl_state_pkg::LcCnt0,
     test_unlock_token: '0,
     test_exit_token: '0,
     rma_token: '0,
diff --git a/util/design/gen-otp-img.py b/util/design/gen-otp-img.py
new file mode 100755
index 0000000..af44957
--- /dev/null
+++ b/util/design/gen-otp-img.py
@@ -0,0 +1,186 @@
+#!/usr/bin/env python3
+# Copyright lowRISC contributors.
+# Licensed under the Apache License, Version 2.0, see LICENSE for details.
+# SPDX-License-Identifier: Apache-2.0
+r"""Generate RTL and documentation collateral from OTP memory
+map definition file (hjson).
+"""
+import argparse
+import datetime
+import logging as log
+import random
+from pathlib import Path
+
+import hjson
+from lib.common import wrapped_docstring
+from lib.OtpMemImg import OtpMemImg
+
+# Get the memory map definition.
+MMAP_DEFINITION_FILE = 'hw/ip/otp_ctrl/data/otp_ctrl_mmap.hjson'
+# Life cycle state and ECC poly definitions.
+LC_STATE_DEFINITION_FILE = 'hw/ip/lc_ctrl/data/lc_ctrl_state.hjson'
+# Default image file definition (can be overridden on the command line).
+IMAGE_DEFINITION_FILE = 'hw/ip/otp_ctrl/data/otp_ctrl_img_dev.hjson'
+# Default output path (can be overridden on the command line).
+MEMORY_HEX_FILE = 'otp-img.mem'
+
+
+def _override_seed(args, name, config):
+    '''Override the seed key in config with value specified in args'''
+    arg_seed = getattr(args, name)
+    if arg_seed:
+        log.warning('Commandline override of {} with {}.'.format(
+            name, arg_seed))
+        config['seed'] = arg_seed
+    # Otherwise, we either take it from the .hjson if present, or
+    # randomly generate a new seed if not.
+    else:
+        random.seed()
+        new_seed = random.getrandbits(64)
+        if config.setdefault('seed', new_seed) == new_seed:
+            log.warning('No {} specified, setting to {}.'.format(
+                name, new_seed))
+
+
+def main():
+    log.basicConfig(level=log.INFO, format="%(levelname)s: %(message)s")
+
+    # Make sure the script can also be called from other dirs than
+    # just the project root by adapting the default paths accordingly.
+    proj_root = Path(__file__).parent.joinpath('../../')
+    lc_state_def_file = Path(proj_root).joinpath(LC_STATE_DEFINITION_FILE)
+    mmap_def_file = Path(proj_root).joinpath(MMAP_DEFINITION_FILE)
+    img_def_file = Path(proj_root).joinpath(IMAGE_DEFINITION_FILE)
+    hex_file = Path(MEMORY_HEX_FILE)
+
+    parser = argparse.ArgumentParser(
+        prog="gen-otp-img",
+        description=wrapped_docstring(),
+        formatter_class=argparse.RawDescriptionHelpFormatter)
+    parser.add_argument('--img-seed',
+                        type=int,
+                        metavar='<seed>',
+                        help='''
+                        Custom seed for RNG to compute randomized items in OTP image.
+
+                        Can be used to override the seed value specified in the image
+                        config Hjson.
+                        ''')
+    parser.add_argument('--lc-seed',
+                        type=int,
+                        metavar='<seed>',
+                        help='''
+                        Custom seed for RNG to compute randomized life cycle netlist constants.
+
+                        Note that this seed must coincide with the seed used for generating
+                        the LC state encoding (gen-lc-state-enc.py).
+
+                        This value typically does not need to be specified as it is taken from
+                        the LC state encoding definition Hjson.
+                        ''')
+    parser.add_argument('--otp-seed',
+                        type=int,
+                        metavar='<seed>',
+                        help='''
+                        Custom seed for RNG to compute randomized OTP netlist constants.
+
+                        Note that this seed must coincide with the seed used for generating
+                        the OTP memory map (gen-otp-mmap.py).
+
+                        This value typically does not need to be specified as it is taken from
+                        the OTP memory map definition Hjson.
+                        ''')
+    parser.add_argument('-o',
+                        '--out',
+                        type=Path,
+                        metavar='<path>',
+                        default=hex_file,
+                        help='''
+                        Custom output path for generated hex file.
+                        Defaults to {}
+                        '''.format(hex_file))
+    parser.add_argument('--img-cfg',
+                        type=Path,
+                        metavar='<path>',
+                        default=img_def_file,
+                        help='''
+                        Image configuration file in Hjson format.
+                        Defaults to {}
+                        '''.format(img_def_file))
+    parser.add_argument('--add-cfg',
+                        type=Path,
+                        metavar='<path>',
+                        action='extend',
+                        nargs='+',
+                        default=[],
+                        help='''
+                        Additional image configuration file in Hjson format.
+
+                        This switch can be specified multiple times.
+                        Image configuration files are parsed in the same
+                        order as they are specified on the command line,
+                        and partition item values that are specified multiple
+                        times are overridden in that order.
+
+                        Note that seed values in additional configuration files
+                        are ignored.
+                        ''')
+
+    args = parser.parse_args()
+
+    log.info('Loading LC state definition file {}'.format(lc_state_def_file))
+    with open(lc_state_def_file, 'r') as infile:
+        lc_state_cfg = hjson.load(infile)
+    log.info('Loading OTP memory map definition file {}'.format(mmap_def_file))
+    with open(mmap_def_file, 'r') as infile:
+        otp_mmap_cfg = hjson.load(infile)
+    log.info('Loading main image configuration file {}'.format(args.img_cfg))
+    with open(args.img_cfg, 'r') as infile:
+        img_cfg = hjson.load(infile)
+
+    # If specified, override the seeds.
+    _override_seed(args, 'lc_seed', lc_state_cfg)
+    _override_seed(args, 'otp_seed', otp_mmap_cfg)
+    _override_seed(args, 'img_seed', img_cfg)
+
+    try:
+        otp_mem_img = OtpMemImg(lc_state_cfg, otp_mmap_cfg, img_cfg)
+
+        for f in args.add_cfg:
+            log.info(
+                'Processing additional image configuration file {}'.format(f))
+            log.info('')
+            with open(f, 'r') as infile:
+                cfg = hjson.load(infile)
+                otp_mem_img.override_data(cfg)
+            log.info('')
+
+    except RuntimeError as err:
+        log.error(err)
+        exit(1)
+
+    # Print all defined args into header comment for referqence
+    argstr = ''
+    for arg, argval in sorted(vars(args).items()):
+        if argval:
+            if not isinstance(argval, list):
+                argval = [argval]
+            for a in argval:
+                argname = '-'.join(arg.split('_'))
+                # Get absolute paths for all files specified.
+                a = a.resolve() if isinstance(a, Path) else a
+                argstr += ' \\\n//   --' + argname + ' ' + str(a) + ''
+
+    dt = datetime.datetime.now(datetime.timezone.utc)
+    dtstr = dt.strftime("%a, %d %b %Y %H:%M:%S %Z")
+    memfile_header = '// Generated on {} with\n// $ gen-otp-img.py {}\n//\n'.format(
+        dtstr, argstr)
+
+    hexfile_content = memfile_header + otp_mem_img.streamout_hexfile()
+
+    with open(args.out, 'w') as outfile:
+        outfile.write(hexfile_content)
+
+
+if __name__ == "__main__":
+    main()
diff --git a/util/design/lib/LcStEnc.py b/util/design/lib/LcStEnc.py
index 713d5eb..1f80151 100644
--- a/util/design/lib/LcStEnc.py
+++ b/util/design/lib/LcStEnc.py
@@ -141,6 +141,9 @@
 
     total_width = config['secded']['data_width'] + config['secded']['ecc_width']
 
+    if config['secded']['data_width'] % 8:
+        raise RuntimeError('SECDED data width must be a multiple of 8')
+
     if config['secded']['ecc_width'] != len(config['secded']['ecc_matrix']):
         raise RuntimeError('ECC matrix does not have correct number of rows')
 
diff --git a/util/design/lib/OtpMemImg.py b/util/design/lib/OtpMemImg.py
new file mode 100644
index 0000000..9924516
--- /dev/null
+++ b/util/design/lib/OtpMemImg.py
@@ -0,0 +1,433 @@
+#!/usr/bin/env python3
+# Copyright lowRISC contributors.
+# Licensed under the Apache License, Version 2.0, see LICENSE for details.
+# SPDX-License-Identifier: Apache-2.0
+r"""OTP memory image class, used to create preload images for the OTP
+memory for simulations and FPGA emulation.
+"""
+
+import logging as log
+import random
+
+from lib.common import check_bool, check_int, ecc_encode, random_or_hexvalue
+from lib.LcStEnc import LcStEnc
+from lib.OtpMemMap import OtpMemMap
+from lib.Present import Present
+
+# Seed diversification constant for OtpMemImg (this enables to use
+# the same seed for different classes)
+OTP_IMG_SEED_DIVERSIFIER = 1941661965323525198146
+
+
+def _present_64bit_encrypt(plain, key):
+    '''Scramble a 64bit block with PRESENT cipher'''
+
+    # Make sure data is within 64bit range
+    assert (plain >= 0) and (plain < 2**64), \
+        'Data block is out of 64bit range'
+
+    # Make sure key is within 128bit range
+    assert (key >= 0) and (key < 2**128), \
+        'Key is out of 128bit range'
+
+    # Make sure inputs are integers
+    assert isinstance(plain, int) and isinstance(key, int), \
+        'Data and key need to be of type int'
+
+    cipher = Present(key, rounds=32, keylen=128)
+    return cipher.encrypt(plain)
+
+
+def _present_64bit_digest(data_blocks, iv, const):
+    '''Compute digest over multiple 64bit data blocks'''
+
+    # We need to align the number of data blocks to 2x64bit
+    # for the digest to work properly.
+    if len(data_blocks) % 2 == 1:
+        data_blocks = data_blocks + [data_blocks[-1]]
+
+    # This computes a digest according to a Merkle-Damgard construction
+    # that uses the Davies-Meyer scheme to turn the PRESENT cipher into
+    # a one-way compression function. Digest finalization consists of
+    # a final digest round with a 128bit constant.
+    # See also: https://docs.opentitan.org/hw/ip/otp_ctrl/doc/index.html#scrambling-datapath
+    state = iv
+    last_b64 = None
+    for b64 in data_blocks:
+        if last_b64 is None:
+            last_b64 = b64
+            continue
+
+        b128 = last_b64 + (b64 << 64)
+        state ^= _present_64bit_encrypt(state, b128)
+        last_b64 = None
+
+    assert last_b64 is None
+    return state
+
+
+def _to_hexfile_with_ecc(data, annotation, config):
+    '''Compute ECC and convert into memory hexfile'''
+
+    log.info('Convert to HEX file.')
+
+    data_width = config['secded']['data_width']
+    assert data_width % 8 == 0, \
+        'OTP data width must be a multiple of 8'
+    assert data_width <= 64, \
+        'OTP data width cannot be larger than 64'
+
+    num_words = len(data) * 8 // data_width
+    bytes_per_word = data_width // 8
+    # Byte aligned total width after adding ECC bits
+    bytes_per_word_ecc = (
+        (data_width + config['secded']['ecc_width'] + 7) // 8)
+    bin_format_str = '0' + str(data_width) + 'b'
+    hex_format_str = '0' + str(bytes_per_word_ecc * 2) + 'x'
+    memory_words = '// OTP memory hexfile with {} x {}bit layout\n'.format(
+        num_words, bytes_per_word_ecc * 8)
+    log.info('Memory layout is {} x {}bit (with ECC)'.format(
+        num_words, bytes_per_word_ecc * 8))
+
+    for k in range(num_words):
+        # Assemble native OTP word and uniquify annotation for comments
+        word = 0
+        word_ann = {}
+        for j in range(bytes_per_word):
+            idx = k * bytes_per_word + j
+            word += data[idx] << (j * 8)
+            if annotation[idx] not in word_ann:
+                word_ann.update({annotation[idx]: "1"})
+
+        # This prints the byte offset of the corresponding
+        # payload data in the memory map (excluding ECC bits)
+        annotation_str = ' // {:06x}: '.format(k * bytes_per_word) + ', '.join(
+            word_ann.keys())
+
+        # ECC encode
+        word_bin = format(word, bin_format_str)
+        word_bin = ecc_encode(config, word_bin)
+        word_hex = format(int(word_bin, 2), hex_format_str)
+        memory_words += word_hex + annotation_str + '\n'
+
+    log.info('Done.')
+
+    return memory_words
+
+
+def _check_unused_keys(dict_to_check, msg_postfix=""):
+    '''If there are unused keys, print their names and error out'''
+    for key in dict_to_check.keys():
+        log.info("Unused key {} in {}".format(key, msg_postfix))
+    if dict_to_check:
+        raise RuntimeError('Aborting due to unused keys in config dict')
+
+
+class OtpMemImg(OtpMemMap):
+
+    # LC state object
+    lc_state = []
+
+    def __init__(self, lc_state_config, otp_mmap_config, img_config):
+
+        # Initialize memory map
+        super().__init__(otp_mmap_config)
+
+        # Initialize the LC state and OTP memory map objects first, since
+        # validation and image generation depends on them
+        self.lc_state = LcStEnc(lc_state_config)
+
+        # Validate memory image configuration
+        log.info('')
+        log.info('Parse OTP image specification.')
+
+        # Encryption smoke test with known test vector
+        enc_test = _present_64bit_encrypt(
+            0x0123456789abcdef,
+            0x0123456789abcdef0123456789abcdef)
+
+        assert enc_test == 0x0e9d28685e671dd6, \
+            'Encryption module test failed'
+
+        otp_width = self.config['otp']['width'] * 8
+        secded_width = self.lc_state.config['secded']['data_width']
+        if otp_width != secded_width:
+            raise RuntimeError('OTP width and SECDED data width must be equal')
+
+        if 'seed' not in img_config:
+            raise RuntimeError('Missing seed in configuration.')
+
+        img_config['seed'] = check_int(img_config['seed'])
+        log.info('Seed: {0:x}'.format(img_config['seed']))
+        log.info('')
+
+        # Re-initialize with seed to make results reproducible.
+        random.seed(OTP_IMG_SEED_DIVERSIFIER + img_config['seed'])
+
+        if 'partitions' not in img_config:
+            raise RuntimeError('Missing partitions key in configuration.')
+
+        for part in img_config['partitions']:
+            self.merge_part_data(part)
+            log.info('Adding values to {} partition.'.format(part['name']))
+            for item in part['items']:
+                self.merge_item_data(part, item)
+
+        # Key accounting
+        img_config_check = img_config.copy()
+        del img_config_check['seed']
+        del img_config_check['partitions']
+        _check_unused_keys(img_config_check, 'in image config')
+
+        log.info('')
+        log.info('Parsing OTP image successfully completed.')
+
+    def merge_part_data(self, part):
+        '''This validates and merges the partition data into the memory map dict'''
+
+        part.setdefault('items', [])
+        if not isinstance(part['items'], list):
+            raise RuntimeError('the "items" key must contain a list')
+
+        # Check if partition name exists in memory map
+        part.setdefault('name', 'unknown_name')
+        mmap_part = self.get_part(part['name'])
+        if mmap_part is None:
+            raise RuntimeError('Partition {} does not exist'.format(
+                part['name']))
+
+        # Only partitions with a hardware digest can be locked.
+        part.setdefault('lock', 'false')
+        part['lock'] = check_bool(part['lock'])
+        if part['lock'] and not \
+           mmap_part['hw_digest']:
+            raise RuntimeError(
+                'Partition {} does not contain a hardware digest'.format(
+                    part['name']))
+
+        # Augment memory map datastructure with lock bit.
+        mmap_part['lock'] = part['lock']
+
+        if part['name'] == 'LIFE_CYCLE':
+            part.setdefault('state', 'RAW')
+            part.setdefault('count', 0)
+
+            part['count'] = check_int(part['count'])
+            if len(part['items']) > 0:
+                raise RuntimeError(
+                    'Life cycle items cannot directly be overridden')
+            if part['lock']:
+                raise RuntimeError('Life cycle partition cannot be locked')
+
+            if part['count'] == 0 and part['state'] != "RAW":
+                raise RuntimeError(
+                    'Life cycle transition counter can only be zero in the RAW state'
+                )
+
+            # Augment life cycle partition with correct life cycle encoding
+            state = self.lc_state.encode('lc_state', str(part['state']))
+            count = self.lc_state.encode('lc_cnt', str(part['count']))
+            part['items'] = [{
+                'name': 'LC_STATE',
+                'value': '0x{:X}'.format(state)
+            }, {
+                'name': 'LC_TRANSITION_CNT',
+                'value': '0x{:X}'.format(count)
+            }]
+
+            # Key accounting
+            part_check = part.copy()
+            del part_check['state']
+            del part_check['count']
+        else:
+            # Key accounting
+            part_check = part.copy()
+            if len(part['items']) == 0:
+                log.warning("Partition does not contain any items.")
+
+        # Key accounting
+        del part_check['items']
+        del part_check['name']
+        del part_check['lock']
+        _check_unused_keys(part_check, "in partition {}".format(part['name']))
+
+    def merge_item_data(self, part, item):
+        '''This validates and merges the item data into the memory map dict'''
+        item.setdefault('name', 'unknown_name')
+        item.setdefault('value', '0x0')
+
+        mmap_item = self.get_item(part['name'], item['name'])
+        if mmap_item is None:
+            raise RuntimeError('Item {} does not exist'.format(item['name']))
+
+        item_size = mmap_item['size']
+        random_or_hexvalue(item, 'value', item_size * 8)
+        mmap_item['value'] = item['value']
+
+        log.info('> Adding item {} with size {}B and value:'.format(
+            item['name'], item_size))
+        fmt_str = '{:0' + str(item_size * 2) + 'x}'
+        value_str = fmt_str.format(item['value'])
+        bytes_per_line = 8
+        j = 0
+        while value_str:
+            # Print out max 64bit per line
+            line_str = ''
+            for k in range(bytes_per_line):
+                num_chars = min(len(value_str), 2)
+                line_str += value_str[-num_chars:]
+                if k < bytes_per_line - 1:
+                    line_str += ' '
+                value_str = value_str[:len(value_str) - num_chars]
+            log.info('  {:06x}: '.format(j) + line_str)
+            j += bytes_per_line
+
+        # Key accounting
+        item_check = item.copy()
+        del item_check['name']
+        del item_check['value']
+        _check_unused_keys(item_check, 'in item {}'.format(item['name']))
+
+    def override_data(self, img_config):
+        '''Override specific partition items'''
+
+        if 'partitions' not in img_config:
+            raise RuntimeError('Missing partitions key in configuration.')
+
+        if not isinstance(img_config['partitions'], list):
+            raise RuntimeError('the "partitions" key must contain a list')
+
+        for part in img_config['partitions']:
+            self.merge_part_data(part)
+            log.info('Overriding values of {} partition.'.format(part['name']))
+            for item in part['items']:
+                self.merge_item_data(part, item)
+
+        # Key accounting
+        img_config_check = img_config.copy()
+        del img_config_check['seed']
+        del img_config_check['partitions']
+        _check_unused_keys(img_config_check, 'in image config')
+
+    def streamout_partition(self, part):
+        '''Scramble and stream out partition data as a list of bytes'''
+
+        part_name = part['name']
+        log.info('Streamout of partition {}'.format(part_name))
+
+        part_offset = part['offset']
+        part_size = part['size']
+        assert part_size % 8 == 0, 'Partition must be 64bit aligned'
+
+        # First chop up all items into individual bytes.
+        data_bytes = [0] * part_size
+        # Annotation is propagated into the hexfile as comments
+        annotation = ['unallocated'] * part_size
+        # Need to keep track of defined items for the scrambling.
+        # Undefined regions are left blank (0x0) in the memory.
+        defined = [False] * part_size
+        for item in part['items']:
+            for k in range(item['size']):
+                idx = item['offset'] - part_offset + k
+                annotation[idx] = part_name + ': ' + item['name']
+                if 'value' in item:
+                    data_bytes[idx] = ((item['value'] >> (8 * k)) & 0xFF)
+                    assert not defined[idx], "Unexpected item collision"
+                    defined[idx] = True
+
+        # Reshape this into 64bit blocks (this must be aligned at this point)
+        assert len(data_bytes) % 8 == 0, 'data_bytes must be 64bit aligned'
+
+        data_blocks = []
+        data_block_defined = []
+        for k, b in enumerate(data_bytes):
+            if (k % 8) == 0:
+                data_blocks.append(b)
+                data_block_defined.append(defined[k])
+            else:
+                data_blocks[k // 8] += (b << 8 * (k % 8))
+                # If any of the individual bytes are defined, the
+                # whole block is considered defined.
+                data_block_defined[k // 8] |= defined[k]
+
+        # Check if scrambling is needed
+        if part['secret']:
+            part_name = part['name']
+            key_sel = part['key_sel']
+            log.info('> Scramble partition with key "{}"'.format(key_sel))
+
+            for key in self.config['scrambling']['keys']:
+                if key['name'] == key_sel:
+                    break
+            else:
+                raise RuntimeError(
+                    'Scrambling key cannot be found {}'.format(key_sel))
+
+            for k in range(len(data_blocks)):
+                if data_block_defined[k]:
+                    data_blocks[k] = _present_64bit_encrypt(
+                        data_blocks[k], key['value'])
+
+        # Check if digest calculation is needed
+        if part['hw_digest']:
+            # Make sure that this HW-governed digest has not been
+            # overridden manually
+            if data_blocks[-1] != 0:
+                raise RuntimeError(
+                    'Digest of partition {} cannot be overridden manually'
+                    .format(part_name))
+
+            # Digest is stored in last block of a partition
+            if part.setdefault('lock', False):
+                log.info('> Lock partition by computing digest')
+                # Digest constants at index 0 are used to compute the
+                # consistency digest
+                iv = self.config['scrambling']['digests'][0]['iv_value']
+                const = self.config['scrambling']['digests'][0]['cnst_value']
+
+                data_blocks[-1] = _present_64bit_digest(
+                    data_blocks[0:-1], iv, const)
+            else:
+                log.info('> Partition is not locked, hence no digest is computed')
+
+        # Convert to a list of bytes to make final packing into
+        # OTP memory words independent of the cipher block size.
+        data = []
+        for block in data_blocks:
+            for k in range(8):
+                data.append((block >> (8 * k)) & 0xFF)
+
+        # Make sure this has the right size
+        assert len(data) == part['size'], 'Partition size mismatch'
+
+        # The annotation list contains a string for each byte
+        # that can be used to print out informative comments
+        # in the memory hex file.
+        return data, annotation
+
+    def streamout_hexfile(self):
+        '''Streamout of memory image in hex file format'''
+
+        log.info('Scramble and stream out partitions.')
+        log.info('')
+        otp_size = self.config['otp']['size']
+        data = [0] * otp_size
+        annotation = [''] * otp_size
+        for part in self.config['partitions']:
+            part_data, part_annotation = self.streamout_partition(part)
+            assert part['offset'] <= otp_size, \
+                'Partition offset out of bounds'
+            idx_low = part['offset']
+            idx_high = part['offset'] + part['size']
+            data[idx_low:idx_high] = part_data
+            annotation[idx_low:idx_high] = part_annotation
+
+        log.info('')
+        log.info('Streamout successfully completed.')
+
+        # Smoke checks
+        assert len(data) <= otp_size, 'Data size mismatch'
+        assert len(annotation) <= otp_size, 'Annotation size mismatch'
+        assert len(data) == len(annotation), 'Data/Annotation size mismatch'
+
+        return _to_hexfile_with_ecc(data, annotation, self.lc_state.config)
diff --git a/util/design/lib/OtpMemMap.py b/util/design/lib/OtpMemMap.py
index df5b4db..19dd65d 100644
--- a/util/design/lib/OtpMemMap.py
+++ b/util/design/lib/OtpMemMap.py
@@ -150,34 +150,40 @@
     for key in config["scrambling"]["keys"]:
         key_names.append(key["name"])
 
+    if not isinstance(config['partitions'], list):
+        raise RuntimeError('the "partitions" key must contain a list')
+
     offset = 0
-    part_index = {}
+    part_dict = {}
     for j, part in enumerate(config["partitions"]):
         _validate_part(part, offset, key_names)
 
-        if part['name'] in part_index:
+        if part['name'] in part_dict:
             raise RuntimeError('Partition name {} is not unique'.format(
                 part['name']))
 
+        if not isinstance(part['items'], list):
+            raise RuntimeError('the "items" key must contain a list')
+
         # Loop over items within a partition
-        item_index = {}
+        item_dict = {}
         for k, item in enumerate(part["items"]):
             _validate_item(item, offset)
-            if item['name'] in item_index:
+            if item['name'] in item_dict:
                 raise RuntimeError('Item name {} is not unique'.format(
                     item['name']))
             log.info("> Item {} at offset {} with size {}".format(
                 item["name"], offset, item["size"]))
             offset += check_int(item["size"])
-            item_index[item['name']] = k
+            item_dict[item['name']] = k
 
         # Place digest at the end of a partition.
         if part["sw_digest"] or part["hw_digest"]:
             digest_name = part["name"] + DIGEST_SUFFIX
-            if digest_name in item_index:
+            if digest_name in item_dict:
                 raise RuntimeError(
                     'Digest name {} is not unique'.format(digest_name))
-            item_index[digest_name] = len(part["items"])
+            item_dict[digest_name] = len(part["items"])
             part["items"].append({
                 "name":
                 digest_name,
@@ -221,9 +227,9 @@
 
         offset = check_int(part["offset"]) + check_int(part["size"])
 
-        part_index.setdefault(part['name'], {
+        part_dict.setdefault(part['name'], {
             'index': j,
-            'items': item_index
+            'items': item_dict
         })
 
     if offset > config["otp"]["size"]:
@@ -237,7 +243,7 @@
     log.info("Bytes required for partitions: {}".format(offset))
 
     # return the partition/item index dict
-    return part_index
+    return part_dict
 
 
 class OtpMemMap():
@@ -245,7 +251,7 @@
     # This holds the config dict.
     config = {}
     # This holds the partition/item index dict for fast access.
-    part_index = {}
+    part_dict = {}
 
     def __init__(self, config):
 
@@ -275,7 +281,7 @@
         # Validate scrambling info.
         _validate_scrambling(config["scrambling"])
         # Validate memory map.
-        self.part_index = _validate_mmap(config)
+        self.part_dict = _validate_mmap(config)
 
         self.config = config
 
@@ -375,13 +381,18 @@
                         tablefmt="pipe",
                         colalign=colalign)
 
-    def get_part_idx(self, part_name):
-        ''' Get partition index, return -1 if it does not exist'''
-        part_index = self.part_index.get(part_name)
-        return -1 if part_index is None else part_index['index']
+    def get_part(self, part_name):
+        ''' Get partition by name, return None if it does not exist'''
+        entry = self.part_dict.get(part_name)
+        return (None if entry is None else
+                self.config['partitions'][entry['index']])
 
-    def get_item_index(self, part_name, item_name):
-        ''' Get item index, return -1 if it does not exist'''
-        part_index = self.part_index.get(part_name)
-        return (-1 if part_index is None else part_index['items'].get(
-            item_name, -1))
+    def get_item(self, part_name, item_name):
+        ''' Get item by name, return None if it does not exist'''
+        entry = self.part_dict.get(part_name)
+        if entry is not None:
+            idx = entry['items'].get(item_name, None)
+            return (None if idx is None else
+                    self.config['partitions'][entry['index']]['items'][idx])
+        else:
+            return None
diff --git a/util/design/lib/common.py b/util/design/lib/common.py
index e4a3576..ef11c34 100644
--- a/util/design/lib/common.py
+++ b/util/design/lib/common.py
@@ -35,8 +35,7 @@
     if isinstance(x, bool):
         return x
     if not x.lower() in ["true", "false"]:
-        log.error("{} is not a boolean value.".format(x))
-        exit(1)
+        raise RuntimeError("{} is not a boolean value.".format(x))
     else:
         return (x.lower() == "true")
 
@@ -48,8 +47,7 @@
     if isinstance(x, int):
         return x
     if not x.isdecimal():
-        log.error("{} is not a decimal number".format(x))
-        exit(1)
+        raise RuntimeError("{} is not a decimal number".format(x))
     return int(x)
 
 
@@ -122,8 +120,7 @@
 def get_hd(word1, word2):
     '''Calculate Hamming distance between two words.'''
     if len(word1) != len(word2):
-        log.error('Words are not of equal size')
-        exit(1)
+        raise RuntimeError('Words are not of equal size')
     return bin(int(word1, 2) ^ int(word2, 2)).count('1')
 
 
@@ -160,8 +157,7 @@
     data_width = config['secded']['data_width']
     ecc_width = config['secded']['ecc_width']
     if len(codeword) != (data_width + ecc_width):
-        log.error("Invalid codeword length {}".format(len(codeword)))
-        exit(1)
+        raise RuntimeError("Invalid codeword length {}".format(len(codeword)))
 
     # Build syndrome and check whether it is zero.
     syndrome = [0 for k in range(ecc_width)]
@@ -178,8 +174,7 @@
 def ecc_encode(config, dataword):
     '''Calculate and prepend ECC bits.'''
     if len(dataword) != config['secded']['data_width']:
-        log.error("Invalid codeword length {}".format(len(dataword)))
-        exit(1)
+        raise RuntimeError("Invalid codeword length {}".format(len(dataword)))
 
     # Note that certain codes like the Hamming code refer to previously
     # calculated parity bits. Hence, we incrementally build the codeword
@@ -223,9 +218,10 @@
         try:
             dict_obj[key] = int(dict_obj[key], 16)
             if dict_obj[key] >= 2**num_bits:
-                log.error('Value is out of range.')
-                exit(1)
+                raise RuntimeError(
+                    'Value "{}" is out of range.'
+                    .format(dict_obj[key]))
         except ValueError:
-            log.error('Invalid value "{}". Must be hex or "<random>".'
-                      .format(dict_obj[key]))
-            exit(1)
+            raise RuntimeError(
+                'Invalid value "{}". Must be hex or "<random>".'
+                .format(dict_obj[key]))